@things-factory/notification 10.1.215 → 10.1.222

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.
Files changed (60) hide show
  1. package/dist-client/bootstrap.js +8 -3
  2. package/dist-client/bootstrap.js.map +1 -1
  3. package/dist-client/index.d.ts +1 -0
  4. package/dist-client/index.js +1 -0
  5. package/dist-client/index.js.map +1 -1
  6. package/dist-client/store-once.d.ts +14 -0
  7. package/dist-client/store-once.js +32 -0
  8. package/dist-client/store-once.js.map +1 -0
  9. package/dist-client/tsconfig.tsbuildinfo +1 -1
  10. package/dist-client/viewparts/notice-channel-setting-let.d.ts +32 -0
  11. package/dist-client/viewparts/notice-channel-setting-let.js +175 -0
  12. package/dist-client/viewparts/notice-channel-setting-let.js.map +1 -0
  13. package/dist-server/controllers/index.d.ts +1 -0
  14. package/dist-server/controllers/index.js +1 -0
  15. package/dist-server/controllers/index.js.map +1 -1
  16. package/dist-server/controllers/notice/deliver-notice.d.ts +78 -0
  17. package/dist-server/controllers/notice/deliver-notice.js +101 -0
  18. package/dist-server/controllers/notice/deliver-notice.js.map +1 -0
  19. package/dist-server/controllers/notice/index.d.ts +4 -0
  20. package/dist-server/controllers/notice/index.js +8 -0
  21. package/dist-server/controllers/notice/index.js.map +1 -0
  22. package/dist-server/controllers/notice/notice-channels.d.ts +67 -0
  23. package/dist-server/controllers/notice/notice-channels.js +62 -0
  24. package/dist-server/controllers/notice/notice-channels.js.map +1 -0
  25. package/dist-server/controllers/notice/notice-email.d.ts +28 -0
  26. package/dist-server/controllers/notice/notice-email.js +125 -0
  27. package/dist-server/controllers/notice/notice-email.js.map +1 -0
  28. package/dist-server/controllers/notice/send-notice.d.ts +16 -0
  29. package/dist-server/controllers/notice/send-notice.js +72 -0
  30. package/dist-server/controllers/notice/send-notice.js.map +1 -0
  31. package/dist-server/middlewares/index.d.ts +1 -0
  32. package/dist-server/middlewares/index.js +3 -0
  33. package/dist-server/middlewares/index.js.map +1 -1
  34. package/dist-server/middlewares/notification-middleware.d.ts +19 -0
  35. package/dist-server/middlewares/notification-middleware.js +9 -3
  36. package/dist-server/middlewares/notification-middleware.js.map +1 -1
  37. package/dist-server/service/index.d.ts +1 -1
  38. package/dist-server/service/index.js +3 -1
  39. package/dist-server/service/index.js.map +1 -1
  40. package/dist-server/service/notice-channel/index.d.ts +2 -0
  41. package/dist-server/service/notice-channel/index.js +6 -0
  42. package/dist-server/service/notice-channel/index.js.map +1 -0
  43. package/dist-server/service/notice-channel/notice-channel-resolver.d.ts +23 -0
  44. package/dist-server/service/notice-channel/notice-channel-resolver.js +118 -0
  45. package/dist-server/service/notice-channel/notice-channel-resolver.js.map +1 -0
  46. package/dist-server/service/notice-channel/notice-channel-rules.d.ts +25 -0
  47. package/dist-server/service/notice-channel/notice-channel-rules.js +60 -0
  48. package/dist-server/service/notice-channel/notice-channel-rules.js.map +1 -0
  49. package/dist-server/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +9 -7
  51. package/tests/notice-channel-setting.test.ts +107 -0
  52. package/tests/notice-delivery.test.ts +328 -0
  53. package/tests/open-doors.test.ts +5 -2
  54. package/tests/store-once.test.ts +88 -0
  55. package/translations/en.json +10 -1
  56. package/translations/es.json +10 -1
  57. package/translations/ja.json +10 -1
  58. package/translations/ko.json +10 -1
  59. package/translations/ms.json +10 -1
  60. package/translations/zh.json +10 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/notification",
3
- "version": "10.1.215",
3
+ "version": "10.1.222",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -30,15 +30,17 @@
30
30
  "@material/web": "^2.0.0",
31
31
  "@operato/layout": "^10.0.0",
32
32
  "@operato/styles": "^10.0.0",
33
- "@things-factory/auth-base": "^10.1.215",
34
- "@things-factory/i18n-base": "^10.1.215",
35
- "@things-factory/organization": "^10.1.215",
36
- "@things-factory/setting-base": "^10.1.215",
33
+ "@things-factory/auth-base": "^10.1.218",
34
+ "@things-factory/email-base": "^10.1.208",
35
+ "@things-factory/i18n-base": "^10.1.218",
36
+ "@things-factory/organization": "^10.1.222",
37
+ "@things-factory/setting-base": "^10.1.218",
37
38
  "@things-factory/shell": "^10.1.208",
38
39
  "clipboard": "^2.0.6",
39
40
  "firebase": "^9.14.0",
40
41
  "firebase-admin": "^11.3.0",
41
- "google": "^2.1.0"
42
+ "google": "^2.1.0",
43
+ "marked": "^15.0.0"
42
44
  },
43
- "gitHead": "7de5e80eb1f4e2e268746aee39f820e7563f454d"
45
+ "gitHead": "afdd6a9b1c8b440e39d4fec48bfbad1a0c02fccc"
44
46
  }
@@ -0,0 +1,107 @@
1
+ /*
2
+ * The notice channels on the Settings screen (ADR-0164): mail on or off, and the two Teams webhooks. A webhook address
3
+ * is a secret - it goes in and never comes back; the screen learns only that it is set, and its host.
4
+ *
5
+ * The rules run from the source; the writing reads dist-server (entities need tsc's decorator metadata) - run the
6
+ * package's build:server first.
7
+ */
8
+ import { DataSource } from 'typeorm'
9
+
10
+ import { addDataSource, Domain, EnvVar, NamingStrategy, entities as shellEntities } from '@things-factory/shell'
11
+
12
+ import { noticeChannelState, noticeChannelWrites, secretState, WEBHOOK_NOT_HTTPS } from '../server/service/notice-channel/notice-channel-rules'
13
+ import { NOTICE_EMAIL, NOTICE_TEAMS_CHANNEL } from '../server/controllers/notice/notice-channels'
14
+ import { writeNoticeChannels } from '../dist-server/service/notice-channel/notice-channel-resolver.js'
15
+
16
+ const CHANNEL = 'https://prod-12.koreacentral.logic.azure.com:443/workflows/abc/triggers/manual/paths/invoke?sig=SECRET'
17
+ const OTHER = 'https://prod-34.koreacentral.logic.azure.com/workflows/def?sig=OTHER'
18
+
19
+ describe('what the screen sees', () => {
20
+ test('a webhook shows as set with its host, never as its address', () => {
21
+ const state = noticeChannelState({ [NOTICE_EMAIL]: 'on', [NOTICE_TEAMS_CHANNEL]: CHANNEL })
22
+
23
+ expect(state).toEqual({
24
+ email: true,
25
+ teamsChannel: { set: true, host: 'prod-12.koreacentral.logic.azure.com' }
26
+ })
27
+ expect(JSON.stringify(state)).not.toContain('sig=')
28
+ })
29
+
30
+ test('nothing set is all off', () => {
31
+ expect(noticeChannelState({})).toEqual({ email: false, teamsChannel: { set: false } })
32
+ expect(secretState(' ')).toEqual({ set: false })
33
+ })
34
+ })
35
+
36
+ describe('what a change writes', () => {
37
+ test('mail on and off', () => {
38
+ expect(noticeChannelWrites({ email: true })).toEqual([{ name: NOTICE_EMAIL, value: 'on' }])
39
+ expect(noticeChannelWrites({ email: false })).toEqual([{ name: NOTICE_EMAIL, value: 'off' }])
40
+ })
41
+
42
+ test('a webhook is kept trimmed; an empty one is cleared; one left out is not touched', () => {
43
+ expect(noticeChannelWrites({ teamsChannelWebhook: ` ${CHANNEL} ` })).toEqual([{ name: NOTICE_TEAMS_CHANNEL, value: CHANNEL }])
44
+ expect(noticeChannelWrites({ teamsChannelWebhook: '' })).toEqual([{ name: NOTICE_TEAMS_CHANNEL, value: null }])
45
+ expect(noticeChannelWrites({ teamsWebhook: 'https://x.example' } as any)).toEqual([])
46
+ expect(noticeChannelWrites({})).toEqual([])
47
+ })
48
+
49
+ test('a webhook that is not https is refused by name', () => {
50
+ for (const bad of ['http://x.example/hook', 'not a url', 'javascript:alert(1)']) {
51
+ expect(() => noticeChannelWrites({ teamsChannelWebhook: bad })).toThrow(expect.objectContaining({ code: WEBHOOK_NOT_HTTPS }))
52
+ }
53
+ })
54
+ })
55
+
56
+ describe('writing the domain attributes', () => {
57
+ let dataSource: DataSource
58
+ let home: Domain
59
+
60
+ beforeAll(async () => {
61
+ dataSource = new DataSource({
62
+ type: 'sqlite',
63
+ database: ':memory:',
64
+ synchronize: true,
65
+ dropSchema: true,
66
+ namingStrategy: new NamingStrategy(),
67
+ entities: [...shellEntities],
68
+ logging: false
69
+ })
70
+ await dataSource.initialize()
71
+ addDataSource('default', dataSource)
72
+ home = await dataSource.getRepository(Domain).save({ name: 'home', subdomain: 'home' } as any)
73
+ })
74
+
75
+ afterAll(async () => dataSource?.destroy())
76
+
77
+ const own = () => dataSource.getRepository(EnvVar).find({ where: { domain: { id: home.id } } as any })
78
+
79
+ test('setting writes the domain’s own attributes and answers without the address', async () => {
80
+ const state = await writeNoticeChannels(home, { email: true, teamsChannelWebhook: CHANNEL }, dataSource.manager)
81
+
82
+ expect(state).toEqual({ email: true, teamsChannel: { set: true, host: 'prod-12.koreacentral.logic.azure.com' } })
83
+ expect(JSON.stringify(state)).not.toContain('sig=')
84
+ const rows = Object.fromEntries((await own()).map(row => [row.name, row.value]))
85
+ expect(rows).toEqual({ [NOTICE_EMAIL]: 'on', [NOTICE_TEAMS_CHANNEL]: CHANNEL })
86
+ })
87
+
88
+ test('setting again replaces the value in the same row', async () => {
89
+ await writeNoticeChannels(home, { email: false, teamsChannelWebhook: OTHER }, dataSource.manager)
90
+
91
+ const rows = await own()
92
+ expect(rows).toHaveLength(2)
93
+ expect(Object.fromEntries(rows.map(row => [row.name, row.value]))).toEqual({ [NOTICE_EMAIL]: 'off', [NOTICE_TEAMS_CHANNEL]: OTHER })
94
+ })
95
+
96
+ test('clearing removes the row', async () => {
97
+ const state = await writeNoticeChannels(home, { teamsChannelWebhook: '' }, dataSource.manager)
98
+
99
+ expect(state.teamsChannel).toEqual({ set: false })
100
+ expect((await own()).map(row => row.name)).toEqual([NOTICE_EMAIL])
101
+ })
102
+
103
+ test('a refused webhook writes nothing', async () => {
104
+ await expect(writeNoticeChannels(home, { email: true, teamsChannelWebhook: 'http://x.example' }, dataSource.manager)).rejects.toMatchObject({ code: WEBHOOK_NOT_HTTPS })
105
+ expect(Object.fromEntries((await own()).map(row => [row.name, row.value]))).toEqual({ [NOTICE_EMAIL]: 'off' })
106
+ })
107
+ })
@@ -0,0 +1,328 @@
1
+ /*
2
+ * A notice goes in the app and by web push always, by mail and Teams when the domain turned them on (ADR-0164).
3
+ * A channel that fails does not stop the others, and nothing throws - what the notice tells of has already happened.
4
+ *
5
+ * The channels are fakes: what each one was handed is what is checked.
6
+ */
7
+ import { deliverNotice, type Notice, type NoticeChannelFunctions } from '../server/controllers/notice/deliver-notice'
8
+ import { languageOf, noticeChannels, NOTICE_EMAIL, NOTICE_TEAMS_CHANNEL, teamsMessage } from '../server/controllers/notice/notice-channels'
9
+ import { escapeHtml, noticeEmailForm, renderNoticeMarkdown, safeNoticeHref } from '../server/controllers/notice/notice-email'
10
+
11
+ type Calls = { push: any[]; mail: any[]; teams: { url: string; body: any }[]; reports: string[] }
12
+
13
+ function fakeChannels(attributes: Record<string, string>, overrides: Partial<NoticeChannelFunctions> = {}): { channels: NoticeChannelFunctions; calls: Calls } {
14
+ const calls: Calls = { push: [], mail: [], teams: [], reports: [] }
15
+ const channels: NoticeChannelFunctions = {
16
+ attributes: async () => attributes,
17
+ push: payload => void calls.push.push(payload),
18
+ mail: async mail => (calls.mail.push(mail), { messageId: 'm' }),
19
+ teams: async (url, body) => (calls.teams.push({ url, body }), { ok: true, status: 202 }),
20
+ report: message => void calls.reports.push(message),
21
+ ...overrides
22
+ }
23
+ return { channels, calls }
24
+ }
25
+
26
+ const notice = (over: Partial<Notice> = {}): Notice => ({
27
+ type: 'approval',
28
+ receivers: [
29
+ { id: 'u-kim', email: 'kim@example.com', locale: 'ko-KR' },
30
+ { id: 'u-lee', email: 'lee@example.com', locale: 'en-US' }
31
+ ],
32
+ words: lng => ({ subject: `${lng}: subject`, body: `${lng}: body`, action: `${lng}: open`, note: `${lng}: see the box` }),
33
+ address: 'activity-approval/a-1',
34
+ link: 'https://office.example.com/domain/hatiolab/activity-approval/a-1',
35
+ defaultLanguage: 'ko',
36
+ label: 'test notice',
37
+ ...over
38
+ })
39
+
40
+ const CHANNEL_HOOK = 'https://prod.example.logic.azure.com/workflows/x'
41
+ const ALL_ON = { [NOTICE_EMAIL]: 'on', [NOTICE_TEAMS_CHANNEL]: CHANNEL_HOOK }
42
+
43
+ describe('notice channels a domain turns on', () => {
44
+ test('mail is on only when the domain says on', () => {
45
+ expect(noticeChannels({ [NOTICE_EMAIL]: 'on' }).email).toBe(true)
46
+ expect(noticeChannels({ [NOTICE_EMAIL]: ' ON ' }).email).toBe(true)
47
+ expect(noticeChannels({ [NOTICE_EMAIL]: 'true' }).email).toBe(false)
48
+ expect(noticeChannels({}).email).toBe(false)
49
+ expect(noticeChannels(undefined).email).toBe(false)
50
+ })
51
+
52
+ test('Teams takes an https channel webhook and nothing else', () => {
53
+ expect(noticeChannels({ [NOTICE_TEAMS_CHANNEL]: 'not a url' }).teamsChannelWebhook).toBeUndefined()
54
+ })
55
+
56
+ test('there is no Teams to each person - a per-person webhook is not read', () => {
57
+ expect(noticeChannels({ 'Notice::teamsWebhook': 'https://x.example/hook' })).toEqual({ email: false, teamsChannelWebhook: undefined })
58
+ })
59
+
60
+ test('a person reads their language; a person without one reads the default', () => {
61
+ expect(languageOf('ko-KR', 'en')).toBe('ko')
62
+ expect(languageOf('zh_CN', 'en')).toBe('zh')
63
+ expect(languageOf(null, 'ko-KR')).toBe('ko')
64
+ expect(languageOf(undefined, undefined)).toBe('en')
65
+ })
66
+ })
67
+
68
+ describe('delivering a notice', () => {
69
+ test('in the app and by web push, to each person alone, with nothing else turned on', async () => {
70
+ const { channels, calls } = fakeChannels({})
71
+ const result = await deliverNotice(notice(), channels)
72
+
73
+ expect(calls.push.map(p => [p.receivers, p.mode])).toEqual([
74
+ [['u-kim'], 'foreground'],
75
+ [['u-kim'], 'background'],
76
+ [['u-lee'], 'foreground'],
77
+ [['u-lee'], 'background']
78
+ ])
79
+ expect(calls.push[0]).toMatchObject({ type: 'approval', subject: 'info', title: 'ko: subject', body: 'ko: body', url: 'activity-approval/a-1' })
80
+ expect(calls.mail).toEqual([])
81
+ expect(calls.teams).toEqual([])
82
+ expect(result).toEqual({ receivers: 2, email: { sent: 0, failed: 0 }, teams: { sent: 0, failed: 0 } })
83
+ })
84
+
85
+ test('by mail too when the domain turned it on, each in the person’s language - a notice to some people never goes to Teams', async () => {
86
+ const { channels, calls } = fakeChannels(ALL_ON)
87
+ const result = await deliverNotice(notice(), channels)
88
+
89
+ expect(calls.mail.map(m => [m.receiver, m.subject])).toEqual([
90
+ ['kim@example.com', 'ko: subject'],
91
+ ['lee@example.com', 'en: subject']
92
+ ])
93
+ expect(calls.mail[0].content).toContain('href="https://office.example.com/domain/hatiolab/activity-approval/a-1"')
94
+ expect(calls.teams).toEqual([])
95
+ expect(result).toEqual({ receivers: 2, email: { sent: 2, failed: 0 }, teams: { sent: 0, failed: 0 } })
96
+ })
97
+
98
+ test('a sender that says no mail for this notice is heard; it cannot turn on what the domain has not', async () => {
99
+ const off = fakeChannels(ALL_ON)
100
+ await deliverNotice(notice({ channels: { email: false } }), off.channels)
101
+ expect(off.calls.mail).toEqual([])
102
+
103
+ const notOn = fakeChannels({})
104
+ await deliverNotice(notice({ channels: { email: true, teams: true } }), notOn.channels)
105
+ expect(notOn.calls.mail).toEqual([])
106
+ expect(notOn.calls.teams).toEqual([])
107
+ })
108
+
109
+ test('a person with no address gets the app and push only', async () => {
110
+ const { channels, calls } = fakeChannels(ALL_ON)
111
+ await deliverNotice(notice({ receivers: [{ id: 'u-park', email: null }] }), channels)
112
+
113
+ expect(calls.push).toHaveLength(2)
114
+ expect(calls.mail).toEqual([])
115
+ expect(calls.teams).toEqual([])
116
+ })
117
+
118
+ test('a failing channel is reported by name and the others still go - nothing throws', async () => {
119
+ const { channels, calls } = fakeChannels(ALL_ON, {
120
+ push: () => {
121
+ throw new Error('fcm down')
122
+ },
123
+ mail: async () => undefined,
124
+ teams: async () => {
125
+ throw new Error('teams down')
126
+ }
127
+ })
128
+
129
+ const result = await deliverNotice(notice({ broadcast: true }), channels)
130
+
131
+ expect(result).toEqual({ receivers: 2, email: { sent: 0, failed: 2 }, teams: { sent: 0, failed: 1 } })
132
+ expect(calls.reports.filter(r => r.includes('foreground notice'))).toHaveLength(2)
133
+ expect(calls.reports.filter(r => r.includes('did not go out'))).toHaveLength(2)
134
+ expect(calls.reports.filter(r => r.includes('Teams channel post'))).toHaveLength(1)
135
+ })
136
+
137
+ test('Teams answering no is a failure', async () => {
138
+ const { channels, calls } = fakeChannels(ALL_ON, { teams: async () => ({ ok: false, status: 400 }) })
139
+ const result = await deliverNotice(notice({ broadcast: true }), channels)
140
+
141
+ expect(result.teams).toEqual({ sent: 0, failed: 1 })
142
+ expect(calls.reports.some(r => r.includes('(400)'))).toBe(true)
143
+ })
144
+
145
+ test('channels that cannot be read leave the app and push', async () => {
146
+ const { channels, calls } = fakeChannels({}, {
147
+ attributes: async () => {
148
+ throw new Error('db')
149
+ }
150
+ })
151
+ await deliverNotice(notice(), channels)
152
+
153
+ expect(calls.push).toHaveLength(4)
154
+ expect(calls.mail).toEqual([])
155
+ expect(calls.reports[0]).toContain('could not be read')
156
+ })
157
+
158
+ test('no receivers, nothing sent', async () => {
159
+ const { channels, calls } = fakeChannels(ALL_ON)
160
+ await deliverNotice(notice({ receivers: [] }), channels)
161
+
162
+ expect(calls.push).toEqual([])
163
+ expect(calls.mail).toEqual([])
164
+ })
165
+ })
166
+
167
+ describe('what a notice carries', () => {
168
+ const words = { subject: '[결재 요청] <b>A&B</b>', body: 'line "one"\nline two', action: '열기', note: '결재함에서 확인' }
169
+
170
+ test('the mail shows values as text', () => {
171
+ const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a?b=1&c="2"' })
172
+
173
+ expect(html).toContain('&lt;b&gt;A&amp;B&lt;/b&gt;')
174
+ expect(html).not.toContain('<b>A&B</b>')
175
+ expect(html).toContain('href="https://x.example/a?b=1&amp;c=&quot;2&quot;"')
176
+ expect(escapeHtml(`'`)).toBe('&#39;')
177
+ })
178
+
179
+ test('with no link the mail shows the note instead of a button', () => {
180
+ const html = noticeEmailForm({ lang: 'ko', words })
181
+
182
+ expect(html).not.toContain('href=')
183
+ expect(html).toContain('결재함에서 확인')
184
+ })
185
+
186
+ test('the Teams message is one Adaptive Card for the channel, the link as its action', () => {
187
+ const message = teamsMessage({ words, link: 'https://x.example/a' })
188
+
189
+ expect(message).toMatchObject({ type: 'message', summary: words.subject })
190
+ expect(message).not.toHaveProperty('recipient')
191
+ expect(message.attachments).toHaveLength(1)
192
+ expect(message.attachments[0].contentType).toBe('application/vnd.microsoft.card.adaptive')
193
+ expect(message.attachments[0].content.actions).toEqual([{ type: 'Action.OpenUrl', title: '열기', url: 'https://x.example/a' }])
194
+
195
+ const noLink = teamsMessage({ words })
196
+ expect(noLink.attachments[0].content.actions).toEqual([])
197
+ expect(noLink.attachments[0].content.body[1].text).toContain('결재함에서 확인')
198
+ })
199
+ })
200
+
201
+ describe('a Markdown body in the mail (an announcement)', () => {
202
+ const BASE = 'https://office.example.com/domain/hatiolab/'
203
+
204
+ test('it is rendered as mail HTML', () => {
205
+ const html = renderNoticeMarkdown('# Title\n\n**bold** and a list:\n\n- one\n- two', BASE)
206
+
207
+ expect(html).toContain('<h1>Title</h1>')
208
+ expect(html).toContain('<strong>bold</strong>')
209
+ expect(html).toContain('<li>one</li>')
210
+ })
211
+
212
+ test('raw HTML is shown as text, never as markup', () => {
213
+ const html = renderNoticeMarkdown('<script>alert(1)</script>\n\nhi <img src=x onerror=alert(1)> <b>b</b>', BASE)
214
+
215
+ expect(html).not.toMatch(/<script|<img|<b>/)
216
+ expect(html).toContain('&lt;script&gt;')
217
+ })
218
+
219
+ test('links go to http(s), mailto and app addresses only', () => {
220
+ expect(safeNoticeHref('https://hatiolab.com/a', BASE)).toBe('https://hatiolab.com/a')
221
+ expect(safeNoticeHref('mailto:hr@hatiolab.com', BASE)).toBe('mailto:hr@hatiolab.com')
222
+ expect(safeNoticeHref('announcement-view?id=1', BASE)).toBe(`${BASE}announcement-view?id=1`)
223
+
224
+ for (const bad of ['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'file:///etc/passwd']) {
225
+ expect(safeNoticeHref(bad, BASE)).toBeUndefined()
226
+ }
227
+ })
228
+
229
+ test('an app address has nowhere to go without the domain’s address', () => {
230
+ expect(safeNoticeHref('announcement-view?id=1')).toBeUndefined()
231
+ expect(safeNoticeHref('https://hatiolab.com/a')).toBe('https://hatiolab.com/a')
232
+ })
233
+
234
+ test('a link that is not allowed keeps its text and loses its address; an image is a link, never loaded', () => {
235
+ const html = renderNoticeMarkdown('[click](javascript:alert(1)) [form](announcement-view?id=1) ![logo](https://cdn.example.com/l.png)', BASE)
236
+
237
+ expect(html).not.toContain('javascript:')
238
+ expect(html).toContain('click')
239
+ expect(html).toContain(`<a href="${BASE}announcement-view?id=1">form</a>`)
240
+ expect(html).not.toContain('<img')
241
+ expect(html).toContain('<a href="https://cdn.example.com/l.png">logo</a>')
242
+ })
243
+
244
+ test('the mail renders a Markdown notice; push shows the summary; the type stays as given', async () => {
245
+ const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' })
246
+ await deliverNotice(
247
+ notice({
248
+ type: 'announcement',
249
+ format: 'markdown',
250
+ address: 'announcement-view?id=n-1',
251
+ link: 'https://office.example.com/domain/hatiolab/announcement-view?id=n-1',
252
+ receivers: [{ id: 'u-kim', email: 'kim@example.com' }],
253
+ words: () => ({ subject: '공지', body: '**휴무** 안내 [규정](rules-view?id=r-1)', summary: '휴무 안내', action: '열기', note: '' })
254
+ }),
255
+ channels
256
+ )
257
+
258
+ expect(calls.push.map(p => [p.type, p.body])).toEqual([
259
+ ['announcement', '휴무 안내'],
260
+ ['announcement', '휴무 안내']
261
+ ])
262
+ expect(calls.mail[0].content).toContain('<strong>휴무</strong>')
263
+ expect(calls.mail[0].content).toContain('<a href="https://office.example.com/domain/hatiolab/rules-view?id=r-1">규정</a>')
264
+ })
265
+
266
+ test('a text notice stays text in the mail', async () => {
267
+ const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' })
268
+ await deliverNotice(notice({ receivers: [{ id: 'u-kim', email: 'kim@example.com' }], words: () => ({ subject: 's', body: '**not bold**', action: 'a', note: '' }) }), channels)
269
+
270
+ expect(calls.mail[0].content).toContain('**not bold**')
271
+ expect(calls.push[0].body).toBe('**not bold**')
272
+ })
273
+ })
274
+
275
+ describe('a notice to everyone in the domain (broadcast)', () => {
276
+ const CHANNEL = 'https://prod.example.logic.azure.com/workflows/channel'
277
+ const BOTH = { [NOTICE_EMAIL]: 'on', [NOTICE_TEAMS_CHANNEL]: CHANNEL }
278
+
279
+ test('Teams posts once to the channel, in the default language, with no recipient; push and mail still go to each person', async () => {
280
+ const { channels, calls } = fakeChannels(BOTH)
281
+ const result = await deliverNotice(notice({ broadcast: true, defaultLanguage: 'ko-KR' }), channels)
282
+
283
+ expect(calls.teams).toHaveLength(1)
284
+ expect(calls.teams[0].url).toBe(CHANNEL)
285
+ expect(calls.teams[0].body).not.toHaveProperty('recipient')
286
+ expect(calls.teams[0].body.summary).toBe('ko: subject')
287
+ expect(calls.teams[0].body.attachments[0].content.actions[0].url).toBe(notice().link)
288
+ expect(calls.push).toHaveLength(4)
289
+ expect(calls.mail).toHaveLength(2)
290
+ expect(result).toEqual({ receivers: 2, email: { sent: 2, failed: 0 }, teams: { sent: 1, failed: 0 } })
291
+ })
292
+
293
+ test('a broadcast needs the channel webhook', async () => {
294
+ const { channels, calls } = fakeChannels({ 'Notice::teamsWebhook': 'https://prod.example.logic.azure.com/workflows/person' })
295
+ const result = await deliverNotice(notice({ broadcast: true }), channels)
296
+
297
+ expect(calls.teams).toEqual([])
298
+ expect(result.teams).toEqual({ sent: 0, failed: 0 })
299
+ })
300
+
301
+ test('a notice to some people never goes to the channel', async () => {
302
+ const { channels, calls } = fakeChannels(BOTH)
303
+ await deliverNotice(notice(), channels)
304
+
305
+ expect(calls.teams).toEqual([])
306
+ })
307
+
308
+ test('a sender that says no Teams stops the channel post too', async () => {
309
+ const { channels, calls } = fakeChannels(BOTH)
310
+ await deliverNotice(notice({ broadcast: true, channels: { teams: false } }), channels)
311
+
312
+ expect(calls.teams).toEqual([])
313
+ })
314
+
315
+ test('the channel webhook must be https', () => {
316
+ expect(noticeChannels({ [NOTICE_TEAMS_CHANNEL]: 'http://x.example/hook' }).teamsChannelWebhook).toBeUndefined()
317
+ expect(noticeChannels({ [NOTICE_TEAMS_CHANNEL]: ' https://x.example/hook ' }).teamsChannelWebhook).toBe('https://x.example/hook')
318
+ })
319
+
320
+ test('a refused or failed channel post counts one failure and is reported', async () => {
321
+ const refused = fakeChannels({ [NOTICE_TEAMS_CHANNEL]: CHANNEL }, { teams: async () => ({ ok: false, status: 429 }) })
322
+ expect((await deliverNotice(notice({ broadcast: true }), refused.channels)).teams).toEqual({ sent: 0, failed: 1 })
323
+ expect(refused.calls.reports.some(r => r.includes('channel post (429)'))).toBe(true)
324
+
325
+ const thrown = fakeChannels({ [NOTICE_TEAMS_CHANNEL]: CHANNEL }, { teams: async () => { throw new Error('down') } })
326
+ expect((await deliverNotice(notice({ broadcast: true }), thrown.channels)).teams).toEqual({ sent: 0, failed: 1 })
327
+ })
328
+ })
@@ -7,7 +7,10 @@
7
7
  import { gatesOf, openFields } from '../../../test/helpers/open-doors'
8
8
 
9
9
  /** The category each mutation asks for; anything not named here asks for `notification-rule`. */
10
- const CATEGORY: Record<string, string> = {}
10
+ const CATEGORY: Record<string, string> = {
11
+ /* the notice channels are domain attributes (EnvVar) - the EnvVar screen's privilege opens them (ADR-0164) */
12
+ updateNoticeChannelSetting: 'env-var'
13
+ }
11
14
 
12
15
  /*
13
16
  * A notification is one person's inbox entry, so a privilege category is not what guards it: these doors
@@ -50,5 +53,5 @@ it('every declared mutation asks for its category and admits the domain owner',
50
53
 
51
54
  it('the walk reaches every mutation', () => {
52
55
  /* If the resolver list shrank, the checks above would pass by having nothing to look at. */
53
- expect(Object.keys(gates.Mutation)).toHaveLength(11)
56
+ expect(Object.keys(gates.Mutation)).toHaveLength(12)
54
57
  })
@@ -0,0 +1,88 @@
1
+ /*
2
+ * A notice is kept once in the inbox however many tabs of the app received it (client/store-once.ts).
3
+ *
4
+ * The tabs share one IndexedDB store and each receives the published notice. The fake store yields between reading and
5
+ * writing, as IndexedDB does, so tabs that do not take turns all read "not kept yet" and all write.
6
+ */
7
+ import { NOTICE_STORE_LOCK, sameNotice, storeOnce, type NoticeLocks, type NoticeStore } from '../client/store-once'
8
+
9
+ const tick = () => new Promise(resolve => setTimeout(resolve, 0))
10
+
11
+ function sharedStore(): NoticeStore & { rows: any[] } {
12
+ const rows: any[] = []
13
+ return {
14
+ rows,
15
+ getAll: async () => {
16
+ await tick()
17
+ return [...rows]
18
+ },
19
+ add: async notice => {
20
+ await tick()
21
+ rows.push(notice)
22
+ },
23
+ limit: async () => undefined
24
+ }
25
+ }
26
+
27
+ /** One lock per name, granted in turn - what navigator.locks does for the tabs of one origin. */
28
+ function turnTaking(): NoticeLocks & { names: string[] } {
29
+ const queues = new Map<string, Promise<unknown>>()
30
+ const names: string[] = []
31
+ return {
32
+ names,
33
+ request<T>(name: string, callback: () => Promise<T>): Promise<T> {
34
+ names.push(name)
35
+ const turn = (queues.get(name) ?? Promise.resolve()).then(callback)
36
+ queues.set(name, turn.catch(() => undefined))
37
+ return turn
38
+ }
39
+ }
40
+ }
41
+
42
+ const published = { timestamp: 1793003153772, subject: 'info', type: 'approval', title: '[결재 요청] 휴가 신청', body: '…', url: 'activity-approval/a-1' }
43
+
44
+ test('three tabs that received one notice keep it once', async () => {
45
+ const store = sharedStore()
46
+ const locks = turnTaking()
47
+
48
+ const kept = await Promise.all([1, 2, 3].map(() => storeOnce(store, { ...published }, locks)))
49
+
50
+ expect(store.rows).toHaveLength(1)
51
+ expect(kept.filter(Boolean)).toHaveLength(1)
52
+ expect(new Set(locks.names)).toEqual(new Set([NOTICE_STORE_LOCK]))
53
+ })
54
+
55
+ test('without taking turns the tabs keep it three times - the lock is what keeps it once', async () => {
56
+ const store = sharedStore()
57
+ const noLock: NoticeLocks = { request: (_name, callback) => callback() }
58
+
59
+ await Promise.all([1, 2, 3].map(() => storeOnce(store, { ...published }, noLock)))
60
+
61
+ expect(store.rows).toHaveLength(3)
62
+ })
63
+
64
+ test('two notices are two, even with the same words at another time', async () => {
65
+ const store = sharedStore()
66
+ const locks = turnTaking()
67
+
68
+ await storeOnce(store, published, locks)
69
+ await storeOnce(store, { ...published, timestamp: published.timestamp + 1 }, locks)
70
+ await storeOnce(store, { ...published, title: 'other' }, locks)
71
+
72
+ expect(store.rows).toHaveLength(3)
73
+ })
74
+
75
+ test('a browser without locks keeps the notice', async () => {
76
+ const store = sharedStore()
77
+
78
+ expect(await storeOnce(store, published, undefined)).toBe(true)
79
+ expect(store.rows).toEqual([published])
80
+ })
81
+
82
+ test('a notice is the same one by its time, subject, type, title, body and address', () => {
83
+ expect(sameNotice(published, { ...published, id: 7, __typename: 'Notification' })).toBe(true)
84
+ for (const field of ['timestamp', 'subject', 'type', 'title', 'body', 'url']) {
85
+ expect(sameNotice(published, { ...published, [field]: 'x' })).toBe(false)
86
+ }
87
+ expect(sameNotice(published, undefined)).toBe(false)
88
+ })
@@ -30,5 +30,14 @@
30
30
  "title.notification-rule list": "notification rules",
31
31
  "title.notification-sender": "notification sender",
32
32
  "task.notification.rules.name": "Changing notification rules",
33
- "task.notification.rules.description": "Changes who is told of what."
33
+ "task.notification.rules.description": "Changes who is told of what.",
34
+ "title.notice channels": "mail · Teams notices",
35
+ "label.notice by email": "also send notices by mail",
36
+ "label.teams channel webhook": "Teams channel — announcements to everyone",
37
+ "text.notice webhook set": "set · {host}",
38
+ "text.notice webhook not set": "not set",
39
+ "button.notice webhook save": "save",
40
+ "button.notice webhook clear": "clear",
41
+ "text.notice channels saved": "Saved.",
42
+ "error.notice-webhook-not-https": "A Teams webhook address starts with https://"
34
43
  }
@@ -30,5 +30,14 @@
30
30
  "title.notification-rule list": "reglas de notificación",
31
31
  "title.notification-sender": "remitente de notificaciones",
32
32
  "task.notification.rules.name": "Cambiar reglas de notificación",
33
- "task.notification.rules.description": "Cambia a quién se avisa de qué."
33
+ "task.notification.rules.description": "Cambia a quién se avisa de qué.",
34
+ "title.notice channels": "avisos por correo · Teams",
35
+ "label.notice by email": "enviar también los avisos por correo",
36
+ "label.teams channel webhook": "Canal de Teams — anuncios para todos",
37
+ "text.notice webhook set": "configurado · {host}",
38
+ "text.notice webhook not set": "sin configurar",
39
+ "button.notice webhook save": "guardar",
40
+ "button.notice webhook clear": "borrar",
41
+ "text.notice channels saved": "Guardado.",
42
+ "error.notice-webhook-not-https": "La dirección del webhook de Teams debe empezar por https://"
34
43
  }
@@ -30,5 +30,14 @@
30
30
  "title.notification-rule list": "通知ルール",
31
31
  "title.notification-sender": "通知送信",
32
32
  "task.notification.rules.name": "通知ルールの変更",
33
- "task.notification.rules.description": "誰に何を知らせるかを変更する。"
33
+ "task.notification.rules.description": "誰に何を知らせるかを変更する。",
34
+ "title.notice channels": "メール · Teams 通知",
35
+ "label.notice by email": "通知をメールでも送る",
36
+ "label.teams channel webhook": "Teams チャネル — 全社へのお知らせ",
37
+ "text.notice webhook set": "設定済み · {host}",
38
+ "text.notice webhook not set": "未設定",
39
+ "button.notice webhook save": "保存",
40
+ "button.notice webhook clear": "削除",
41
+ "text.notice channels saved": "保存しました。",
42
+ "error.notice-webhook-not-https": "Teams の Webhook アドレスは https:// で始まる必要があります。"
34
43
  }
@@ -30,5 +30,14 @@
30
30
  "title.notification-rule list": "알림 규칙",
31
31
  "title.notification-sender": "알림 전송",
32
32
  "task.notification.rules.name": "알림 규칙 바꾸기",
33
- "task.notification.rules.description": "누구에게 무엇을 알릴지 바꾼다."
33
+ "task.notification.rules.description": "누구에게 무엇을 알릴지 바꾼다.",
34
+ "title.notice channels": "메일 · Teams 알림",
35
+ "label.notice by email": "알림을 메일로도 보내기",
36
+ "label.teams channel webhook": "Teams 채널 — 사업장 전체 공지",
37
+ "text.notice webhook set": "설정됨 · {host}",
38
+ "text.notice webhook not set": "설정 안 됨",
39
+ "button.notice webhook save": "저장",
40
+ "button.notice webhook clear": "지우기",
41
+ "text.notice channels saved": "저장했습니다.",
42
+ "error.notice-webhook-not-https": "Teams 웹후크 주소는 https:// 로 시작해야 합니다."
34
43
  }
@@ -30,5 +30,14 @@
30
30
  "title.notification-rule list": "peraturan pemberitahuan",
31
31
  "title.notification-sender": "pengirim pemberitahuan",
32
32
  "task.notification.rules.name": "Mengubah peraturan pemberitahuan",
33
- "task.notification.rules.description": "Mengubah siapa diberitahu tentang apa."
33
+ "task.notification.rules.description": "Mengubah siapa diberitahu tentang apa.",
34
+ "title.notice channels": "notis mel · Teams",
35
+ "label.notice by email": "hantar notis melalui mel juga",
36
+ "label.teams channel webhook": "Saluran Teams — pengumuman kepada semua",
37
+ "text.notice webhook set": "ditetapkan · {host}",
38
+ "text.notice webhook not set": "tidak ditetapkan",
39
+ "button.notice webhook save": "simpan",
40
+ "button.notice webhook clear": "kosongkan",
41
+ "text.notice channels saved": "Disimpan.",
42
+ "error.notice-webhook-not-https": "Alamat webhook Teams mesti bermula dengan https://"
34
43
  }