@things-factory/notification 10.1.248 → 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/controllers/fcm.d.ts +6 -10
- package/dist-server/controllers/fcm.js +73 -122
- package/dist-server/controllers/fcm.js.map +1 -1
- package/dist-server/controllers/notice/deliver-notice.d.ts +2 -2
- package/dist-server/controllers/notice/deliver-notice.js +4 -4
- package/dist-server/controllers/notice/deliver-notice.js.map +1 -1
- package/dist-server/controllers/notice/index.d.ts +0 -2
- package/dist-server/controllers/notice/index.js +0 -2
- package/dist-server/controllers/notice/index.js.map +1 -1
- package/dist-server/controllers/notice/notice-channels.d.ts +2 -6
- package/dist-server/controllers/notice/notice-channels.js +2 -9
- package/dist-server/controllers/notice/notice-channels.js.map +1 -1
- package/dist-server/controllers/notice/send-notice.js +1 -2
- package/dist-server/controllers/notice/send-notice.js.map +1 -1
- package/dist-server/middlewares/notification-middleware.d.ts +1 -0
- package/dist-server/middlewares/notification-middleware.js +11 -1
- package/dist-server/middlewares/notification-middleware.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -1
- package/dist-server/service/index.js +4 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/notification-token/index.d.ts +2 -0
- package/dist-server/service/notification-token/index.js +6 -0
- package/dist-server/service/notification-token/index.js.map +1 -0
- package/dist-server/service/notification-token/notification-token.d.ts +22 -0
- package/dist-server/service/notification-token/notification-token.js +64 -0
- package/dist-server/service/notification-token/notification-token.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/tests/notice-delivery.test.ts +1 -116
- package/tests/push-tokens.test.ts +190 -0
- package/dist-server/controllers/notice/app-brand.d.ts +0 -17
- package/dist-server/controllers/notice/app-brand.js +0 -61
- package/dist-server/controllers/notice/app-brand.js.map +0 -1
- package/dist-server/controllers/notice/notice-email.d.ts +0 -32
- package/dist-server/controllers/notice/notice-email.js +0 -153
- package/dist-server/controllers/notice/notice-email.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/notification",
|
|
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,
|
|
@@ -30,17 +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.
|
|
34
|
-
"@things-factory/email-base": "^10.1.
|
|
35
|
-
"@things-factory/i18n-base": "^10.1.
|
|
36
|
-
"@things-factory/organization": "^10.1.
|
|
37
|
-
"@things-factory/setting-base": "^10.1.
|
|
38
|
-
"@things-factory/shell": "^10.1.
|
|
33
|
+
"@things-factory/auth-base": "^10.1.254",
|
|
34
|
+
"@things-factory/email-base": "^10.1.254",
|
|
35
|
+
"@things-factory/i18n-base": "^10.1.254",
|
|
36
|
+
"@things-factory/organization": "^10.1.254",
|
|
37
|
+
"@things-factory/setting-base": "^10.1.254",
|
|
38
|
+
"@things-factory/shell": "^10.1.254",
|
|
39
39
|
"clipboard": "^2.0.6",
|
|
40
40
|
"firebase": "^9.14.0",
|
|
41
41
|
"firebase-admin": "^11.3.0",
|
|
42
42
|
"google": "^2.1.0",
|
|
43
43
|
"marked": "^15.0.0"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "793e2fd617db5bb917ec892e64956fb690f7e7b0"
|
|
46
46
|
}
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
* The channels are fakes: what each one was handed is what is checked.
|
|
6
6
|
*/
|
|
7
7
|
import { deliverNotice, type Notice, type NoticeChannelFunctions } from '../server/controllers/notice/deliver-notice'
|
|
8
|
-
import {
|
|
9
|
-
import { escapeHtml, noticeEmailForm, renderNoticeMarkdown, safeNoticeHref } from '../server/controllers/notice/notice-email'
|
|
10
|
-
import { brandOf, DEFAULT_BRAND, textColorOn } from '../server/controllers/notice/app-brand'
|
|
8
|
+
import { noticeChannels, NOTICE_EMAIL, NOTICE_TEAMS_CHANNEL, teamsMessage } from '../server/controllers/notice/notice-channels'
|
|
11
9
|
|
|
12
10
|
type Calls = { push: any[]; mail: any[]; teams: { url: string; body: any }[]; reports: string[] }
|
|
13
11
|
|
|
@@ -58,12 +56,6 @@ describe('notice channels a domain turns on', () => {
|
|
|
58
56
|
expect(noticeChannels({ 'Notice::teamsWebhook': 'https://x.example/hook' })).toEqual({ email: false, teamsChannelWebhook: undefined })
|
|
59
57
|
})
|
|
60
58
|
|
|
61
|
-
test('a person reads their language; a person without one reads the default', () => {
|
|
62
|
-
expect(languageOf('ko-KR', 'en')).toBe('ko')
|
|
63
|
-
expect(languageOf('zh_CN', 'en')).toBe('zh')
|
|
64
|
-
expect(languageOf(null, 'ko-KR')).toBe('ko')
|
|
65
|
-
expect(languageOf(undefined, undefined)).toBe('en')
|
|
66
|
-
})
|
|
67
59
|
})
|
|
68
60
|
|
|
69
61
|
describe('delivering a notice', () => {
|
|
@@ -168,22 +160,6 @@ describe('delivering a notice', () => {
|
|
|
168
160
|
describe('what a notice carries', () => {
|
|
169
161
|
const words = { subject: '[결재 요청] <b>A&B</b>', body: 'line "one"\nline two', action: '열기', note: '결재함에서 확인' }
|
|
170
162
|
|
|
171
|
-
test('the mail shows values as text', () => {
|
|
172
|
-
const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a?b=1&c="2"' })
|
|
173
|
-
|
|
174
|
-
expect(html).toContain('<b>A&B</b>')
|
|
175
|
-
expect(html).not.toContain('<b>A&B</b>')
|
|
176
|
-
expect(html).toContain('href="https://x.example/a?b=1&c="2""')
|
|
177
|
-
expect(escapeHtml(`'`)).toBe(''')
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
test('with no link the mail shows the note instead of a button', () => {
|
|
181
|
-
const html = noticeEmailForm({ lang: 'ko', words })
|
|
182
|
-
|
|
183
|
-
expect(html).not.toContain('href=')
|
|
184
|
-
expect(html).toContain('결재함에서 확인')
|
|
185
|
-
})
|
|
186
|
-
|
|
187
163
|
test('the Teams message is one Adaptive Card for the channel, the link as its action', () => {
|
|
188
164
|
const message = teamsMessage({ words, link: 'https://x.example/a' })
|
|
189
165
|
|
|
@@ -200,48 +176,6 @@ describe('what a notice carries', () => {
|
|
|
200
176
|
})
|
|
201
177
|
|
|
202
178
|
describe('a Markdown body in the mail (an announcement)', () => {
|
|
203
|
-
const BASE = 'https://office.example.com/domain/hatiolab/'
|
|
204
|
-
|
|
205
|
-
test('it is rendered as mail HTML', () => {
|
|
206
|
-
const html = renderNoticeMarkdown('# Title\n\n**bold** and a list:\n\n- one\n- two', BASE)
|
|
207
|
-
|
|
208
|
-
expect(html).toContain('<h1>Title</h1>')
|
|
209
|
-
expect(html).toContain('<strong>bold</strong>')
|
|
210
|
-
expect(html).toContain('<li>one</li>')
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('raw HTML is shown as text, never as markup', () => {
|
|
214
|
-
const html = renderNoticeMarkdown('<script>alert(1)</script>\n\nhi <img src=x onerror=alert(1)> <b>b</b>', BASE)
|
|
215
|
-
|
|
216
|
-
expect(html).not.toMatch(/<script|<img|<b>/)
|
|
217
|
-
expect(html).toContain('<script>')
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
test('links go to http(s), mailto and app addresses only', () => {
|
|
221
|
-
expect(safeNoticeHref('https://hatiolab.com/a', BASE)).toBe('https://hatiolab.com/a')
|
|
222
|
-
expect(safeNoticeHref('mailto:hr@hatiolab.com', BASE)).toBe('mailto:hr@hatiolab.com')
|
|
223
|
-
expect(safeNoticeHref('announcement-view?id=1', BASE)).toBe(`${BASE}announcement-view?id=1`)
|
|
224
|
-
|
|
225
|
-
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']) {
|
|
226
|
-
expect(safeNoticeHref(bad, BASE)).toBeUndefined()
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
test('an app address has nowhere to go without the domain’s address', () => {
|
|
231
|
-
expect(safeNoticeHref('announcement-view?id=1')).toBeUndefined()
|
|
232
|
-
expect(safeNoticeHref('https://hatiolab.com/a')).toBe('https://hatiolab.com/a')
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
test('a link that is not allowed keeps its text and loses its address; an image is a link, never loaded', () => {
|
|
236
|
-
const html = renderNoticeMarkdown('[click](javascript:alert(1)) [form](announcement-view?id=1) ', BASE)
|
|
237
|
-
|
|
238
|
-
expect(html).not.toContain('javascript:')
|
|
239
|
-
expect(html).toContain('click')
|
|
240
|
-
expect(html).toContain(`<a href="${BASE}announcement-view?id=1">form</a>`)
|
|
241
|
-
expect(html).not.toContain('<img')
|
|
242
|
-
expect(html).toContain('<a href="https://cdn.example.com/l.png">logo</a>')
|
|
243
|
-
})
|
|
244
|
-
|
|
245
179
|
test('the mail renders a Markdown notice; push shows the summary; the type stays as given', async () => {
|
|
246
180
|
const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' })
|
|
247
181
|
await deliverNotice(
|
|
@@ -329,55 +263,6 @@ describe('a notice to everyone in the domain (broadcast)', () => {
|
|
|
329
263
|
})
|
|
330
264
|
|
|
331
265
|
describe('whose mail it is', () => {
|
|
332
|
-
const words = { subject: '[공지] 점검', body: '본문', action: '열기', note: '알림함에서 확인' }
|
|
333
|
-
|
|
334
|
-
test('the brand is the manifest\'s name, tagline and brand colour; the theme colour stands in for a missing brand colour', () => {
|
|
335
|
-
expect(brandOf({ name: 'operato office', tagline: 'Reimagining Business.', brand_color: '#059669', theme_color: '#262e38' })).toEqual({
|
|
336
|
-
name: 'operato office',
|
|
337
|
-
tagline: 'Reimagining Business.',
|
|
338
|
-
color: '#059669'
|
|
339
|
-
})
|
|
340
|
-
expect(brandOf({ name: 'Operato Plant', theme_color: '#0E8A56' })).toEqual({ name: 'Operato Plant', tagline: undefined, color: '#0e8a56' })
|
|
341
|
-
expect(brandOf({ name: ' ', brand_color: 'green', theme_color: '#abc' })).toEqual({ name: DEFAULT_BRAND.name, tagline: undefined, color: '#aabbcc' })
|
|
342
|
-
expect(brandOf(undefined)).toEqual({ name: DEFAULT_BRAND.name, tagline: undefined, color: DEFAULT_BRAND.color })
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
test('the text on the brand colour is white while it keeps 3:1, black below', () => {
|
|
346
|
-
expect(textColorOn('#059669')).toBe('#ffffff')
|
|
347
|
-
expect(textColorOn('#262e38')).toBe('#ffffff')
|
|
348
|
-
expect(textColorOn('#34d399')).toBe('#000000')
|
|
349
|
-
expect(textColorOn('#ffffff')).toBe('#000000')
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
test('the mail names the application and its tagline, and its band and button are the brand colour - no logo image', () => {
|
|
353
|
-
const brand = { name: 'operato <office>', tagline: 'Reimagining Business.', color: '#059669' }
|
|
354
|
-
const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a', brand })
|
|
355
|
-
|
|
356
|
-
expect(html).toContain('operato <office>')
|
|
357
|
-
expect(html).toContain('Reimagining Business.')
|
|
358
|
-
expect(html).not.toContain('<img')
|
|
359
|
-
expect(html).not.toContain('#22a6a7')
|
|
360
|
-
expect(html.match(/background-color:#059669/g)).toHaveLength(2)
|
|
361
|
-
expect(html).toContain('border-top:2px solid #059669')
|
|
362
|
-
|
|
363
|
-
const plain = noticeEmailForm({ lang: 'ko', words })
|
|
364
|
-
expect(plain).toContain(`>${DEFAULT_BRAND.name}<`)
|
|
365
|
-
expect(plain).toContain(`background-color:${DEFAULT_BRAND.color}`)
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
test('the colours hold where a mail program drops styles: each sits on its table cell as bgcolor, the mail says it is light, the tagline has its own cell', () => {
|
|
369
|
-
const brand = { name: 'operato office', tagline: 'Reimagining Business.', color: '#059669' }
|
|
370
|
-
const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a', brand })
|
|
371
|
-
|
|
372
|
-
expect(html).toContain('<meta name="color-scheme" content="light" />')
|
|
373
|
-
expect(html.match(/<td bgcolor="#059669"/g)).toHaveLength(2)
|
|
374
|
-
expect(html).toContain('<td bgcolor="#3d5874"')
|
|
375
|
-
expect(html).toContain('<td bgcolor="#ffffff"')
|
|
376
|
-
expect(html).toMatch(/operato office<\/td>\s*<td[^>]*> Reimagining Business\.<\/td>/)
|
|
377
|
-
expect(html).not.toMatch(/margin-left/)
|
|
378
|
-
expect(html).not.toMatch(/<div style="background-color/)
|
|
379
|
-
})
|
|
380
|
-
|
|
381
266
|
test('the real channels carry the brand to the mail', async () => {
|
|
382
267
|
const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' }, { brand: { name: 'Brand X', tagline: 'Tag Y', color: '#1d4ed8' } })
|
|
383
268
|
await deliverNotice(notice(), channels)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Web push reaches a person at every browser that registered an FCM token for them, sent one token at a time over
|
|
3
|
+
* FCM HTTP v1. A token FCM says is gone is forgotten.
|
|
4
|
+
*
|
|
5
|
+
* Until 2026-11-05 a person was reached through a legacy FCM device group, keyed by a server key no config set and
|
|
6
|
+
* that Google shut down in 2024 - register made no group and notify found none, so web push reached no one.
|
|
7
|
+
*
|
|
8
|
+
* FCM is a fake: what it was handed is what is checked. Reads dist-server (entities need tsc's decorator metadata) -
|
|
9
|
+
* run the package's build:server first.
|
|
10
|
+
*/
|
|
11
|
+
import { DataSource } from 'typeorm'
|
|
12
|
+
|
|
13
|
+
import { NamingStrategy, addDataSource, entities as shellEntities } from '@things-factory/shell'
|
|
14
|
+
import { User, entities as authEntities } from '@things-factory/auth-base'
|
|
15
|
+
import { config } from '@things-factory/env'
|
|
16
|
+
|
|
17
|
+
const mockMessaging = {
|
|
18
|
+
multicasts: [] as any[],
|
|
19
|
+
answer: (tokens: string[]) => tokens.map(() => ({ success: true }) as any),
|
|
20
|
+
async sendEachForMulticast(message: any) {
|
|
21
|
+
this.multicasts.push(message)
|
|
22
|
+
const responses = this.answer(message.tokens)
|
|
23
|
+
return { responses, successCount: responses.filter(r => r.success).length, failureCount: responses.filter(r => !r.success).length }
|
|
24
|
+
},
|
|
25
|
+
async send() {
|
|
26
|
+
return 'sent'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
jest.mock('firebase-admin/app', () => ({ initializeApp: () => ({}), cert: (account: any) => account }))
|
|
31
|
+
jest.mock('firebase-admin/messaging', () => ({ getMessaging: () => mockMessaging }))
|
|
32
|
+
|
|
33
|
+
let dataSource: DataSource
|
|
34
|
+
let fcm: any
|
|
35
|
+
let notifierOf: any
|
|
36
|
+
let deliverNotice: any
|
|
37
|
+
let NotificationToken: any
|
|
38
|
+
let kim: User
|
|
39
|
+
let lee: User
|
|
40
|
+
|
|
41
|
+
const tokensOf = async (user: User) =>
|
|
42
|
+
(await dataSource.getRepository(NotificationToken).find({ where: { user: { id: user.id } } })).map((one: any) => one.token).sort()
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
config.set('notification', { fcm: { serviceAccount: { project_id: 'test' } } })
|
|
46
|
+
fcm = require('../dist-server/controllers/fcm.js')
|
|
47
|
+
NotificationToken = require('../dist-server/service/notification-token/notification-token.js').NotificationToken
|
|
48
|
+
notifierOf = require('../dist-server/middlewares/notification-middleware.js').notifierOf
|
|
49
|
+
deliverNotice = require('../dist-server/controllers/notice/deliver-notice.js').deliverNotice
|
|
50
|
+
|
|
51
|
+
dataSource = new DataSource({
|
|
52
|
+
type: 'sqlite',
|
|
53
|
+
database: ':memory:',
|
|
54
|
+
synchronize: true,
|
|
55
|
+
dropSchema: true,
|
|
56
|
+
namingStrategy: new NamingStrategy(),
|
|
57
|
+
entities: [...shellEntities, ...authEntities, NotificationToken],
|
|
58
|
+
logging: false
|
|
59
|
+
})
|
|
60
|
+
await dataSource.initialize()
|
|
61
|
+
addDataSource('default', dataSource)
|
|
62
|
+
|
|
63
|
+
const users = dataSource.getRepository(User)
|
|
64
|
+
kim = await users.save({ name: 'kim', username: 'kim', email: 'kim@test.com' } as any)
|
|
65
|
+
lee = await users.save({ name: 'lee', username: 'lee', email: 'lee@test.com' } as any)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await dataSource?.destroy()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
await dataSource.getRepository(NotificationToken).clear()
|
|
74
|
+
mockMessaging.multicasts = []
|
|
75
|
+
mockMessaging.answer = tokens => tokens.map(() => ({ success: true }))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('registering a browser', () => {
|
|
79
|
+
test('keeps each browser of a person, once', async () => {
|
|
80
|
+
expect(await fcm.register(kim, { subscription: 'kim-phone' })).toBe(true)
|
|
81
|
+
await fcm.register(kim, { subscription: 'kim-laptop' })
|
|
82
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
83
|
+
|
|
84
|
+
expect(await tokensOf(kim)).toEqual(['kim-laptop', 'kim-phone'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a browser someone else signs in on and turns notices on for moves to them', async () => {
|
|
88
|
+
await fcm.register(kim, { subscription: 'shared-tablet' })
|
|
89
|
+
await fcm.register(lee, { subscription: 'shared-tablet' })
|
|
90
|
+
|
|
91
|
+
expect(await tokensOf(kim)).toEqual([])
|
|
92
|
+
expect(await tokensOf(lee)).toEqual(['shared-tablet'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('turning notices off forgets the browser - only for the person holding it', async () => {
|
|
96
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
97
|
+
|
|
98
|
+
await fcm.unregister(lee, { subscription: 'kim-phone' })
|
|
99
|
+
expect(await tokensOf(kim)).toEqual(['kim-phone'])
|
|
100
|
+
|
|
101
|
+
await fcm.unregister(kim, { subscription: 'kim-phone' })
|
|
102
|
+
expect(await tokensOf(kim)).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('pushing to people', () => {
|
|
107
|
+
test('goes to every browser of each receiver over HTTP v1, with data as strings', async () => {
|
|
108
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
109
|
+
await fcm.register(kim, { subscription: 'kim-laptop' })
|
|
110
|
+
await fcm.register(lee, { subscription: 'lee-phone' })
|
|
111
|
+
|
|
112
|
+
await fcm.notify({
|
|
113
|
+
receivers: [kim.id],
|
|
114
|
+
title: '결재 차례',
|
|
115
|
+
body: '지출결의 1건',
|
|
116
|
+
data: { type: 'approval', url: 'https://office.example.com/domain/hatiolab/activity-approval/a-1', timestamp: 1, missing: undefined }
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(mockMessaging.multicasts).toHaveLength(1)
|
|
120
|
+
const [sent] = mockMessaging.multicasts
|
|
121
|
+
expect([...sent.tokens].sort()).toEqual(['kim-laptop', 'kim-phone'])
|
|
122
|
+
expect(sent.notification).toEqual({ title: '결재 차례', body: '지출결의 1건' })
|
|
123
|
+
expect(sent.data).toEqual({ type: 'approval', url: 'https://office.example.com/domain/hatiolab/activity-approval/a-1', timestamp: '1' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a person with no browser registered is not pushed to', async () => {
|
|
127
|
+
await fcm.notify({ receivers: [lee.id], title: 't', body: 'b', data: {} })
|
|
128
|
+
|
|
129
|
+
expect(mockMessaging.multicasts).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a token FCM says is gone is forgotten; one that failed for another reason is kept', async () => {
|
|
133
|
+
await fcm.register(kim, { subscription: 'kim-old-phone' })
|
|
134
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
135
|
+
mockMessaging.answer = tokens =>
|
|
136
|
+
tokens.map(token =>
|
|
137
|
+
token === 'kim-old-phone'
|
|
138
|
+
? { success: false, error: { code: 'messaging/registration-token-not-registered', message: 'gone' } }
|
|
139
|
+
: { success: false, error: { code: 'messaging/internal-error', message: 'try later' } }
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await fcm.notify({ receivers: [kim.id], title: 't', body: 'b', data: {} })
|
|
143
|
+
|
|
144
|
+
expect(await tokensOf(kim)).toEqual(['kim-phone'])
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* The service worker that opens a web push knows no domain: a page relative to the domain resolved against it loses
|
|
150
|
+
* /domain/<x>/ and a tap lands nowhere. A notice sent with no request (a scheduled reminder) has no full link either.
|
|
151
|
+
*/
|
|
152
|
+
describe('the page a web push opens', () => {
|
|
153
|
+
const domain = { subdomain: 'hatiolab', extType: 'domain' }
|
|
154
|
+
|
|
155
|
+
test('a notice sent with no request opens its page within the domain, from the origin', async () => {
|
|
156
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
157
|
+
|
|
158
|
+
await deliverNotice(
|
|
159
|
+
{
|
|
160
|
+
type: 'approval-overdue',
|
|
161
|
+
receivers: [{ id: kim.id }],
|
|
162
|
+
words: () => ({ subject: '늦은 결재', body: '1건' }),
|
|
163
|
+
address: 'activity-approval/a-1',
|
|
164
|
+
label: 'overdue reminder'
|
|
165
|
+
},
|
|
166
|
+
{ attributes: async () => ({}), push: notifierOf(domain), mail: async () => undefined, teams: async () => ({ ok: true }), report: () => {} }
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(mockMessaging.multicasts.map(sent => sent.data.url)).toEqual(['/domain/hatiolab/activity-approval/a-1'])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('the open screen is told the page within its domain, as it navigates', async () => {
|
|
173
|
+
const { pubsub } = require('@things-factory/shell')
|
|
174
|
+
const published = jest.spyOn(pubsub, 'publish').mockImplementation(async () => undefined)
|
|
175
|
+
|
|
176
|
+
await notifierOf(domain)({ receivers: [kim.id], title: 't', url: 'activity-approval/a-1', mode: 'foreground' })
|
|
177
|
+
|
|
178
|
+
expect((published.mock.calls[0][1] as any).notification.url).toBe('activity-approval/a-1')
|
|
179
|
+
published.mockRestore()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an address with a scheme or from the origin is handed on as it is', async () => {
|
|
183
|
+
await fcm.register(kim, { subscription: 'kim-phone' })
|
|
184
|
+
|
|
185
|
+
await notifierOf(domain)({ receivers: [kim.id], title: 't', url: 'https://example.com/x' })
|
|
186
|
+
await notifierOf(domain)({ receivers: [kim.id], title: 't', url: '/domain/other/y' })
|
|
187
|
+
|
|
188
|
+
expect(mockMessaging.multicasts.map(sent => sent.data.url)).toEqual(['https://example.com/x', '/domain/other/y'])
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export type AppBrand = {
|
|
2
|
-
name: string;
|
|
3
|
-
tagline?: string;
|
|
4
|
-
/** `#rrggbb`. */
|
|
5
|
-
color: string;
|
|
6
|
-
};
|
|
7
|
-
/** What the notice mail had before an application said its own. */
|
|
8
|
-
export declare const DEFAULT_BRAND: AppBrand;
|
|
9
|
-
/** The brand a manifest declares, each part it leaves out the default's. */
|
|
10
|
-
export declare function brandOf(manifest: any): AppBrand;
|
|
11
|
-
/**
|
|
12
|
-
* The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text
|
|
13
|
-
* (WCAG 1.4.3) - black below that.
|
|
14
|
-
*/
|
|
15
|
-
export declare function textColorOn(color: string): '#000000' | '#ffffff';
|
|
16
|
-
/** The running application's brand; the default when it ships no manifest or one that cannot be read. */
|
|
17
|
-
export declare function appBrand(): AppBrand;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_BRAND = void 0;
|
|
4
|
-
exports.brandOf = brandOf;
|
|
5
|
-
exports.textColorOn = textColorOn;
|
|
6
|
-
exports.appBrand = appBrand;
|
|
7
|
-
const tslib_1 = require("tslib");
|
|
8
|
-
/*
|
|
9
|
-
* Whose mail it is - the application's brand name, tagline and colour, read from its web manifest
|
|
10
|
-
* (`assets/manifest.json`), the one identity file every application ships and the image keeps. `name` is the brand
|
|
11
|
-
* name; `tagline` and `brand_color` are the application's own members (a browser passes over members it does not
|
|
12
|
-
* know); with no `brand_color` the manifest's `theme_color` stands in. Read once.
|
|
13
|
-
*/
|
|
14
|
-
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
15
|
-
const path_1 = tslib_1.__importDefault(require("path"));
|
|
16
|
-
const env_1 = require("@things-factory/env");
|
|
17
|
-
/** What the notice mail had before an application said its own. */
|
|
18
|
-
exports.DEFAULT_BRAND = { name: 'Operato', color: '#22a6a7' };
|
|
19
|
-
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
20
|
-
/** A 3-digit colour as its 6 digits; anything not a hex colour is not a colour. */
|
|
21
|
-
function hexOf(value) {
|
|
22
|
-
if (typeof value !== 'string' || !HEX.test(value.trim()))
|
|
23
|
-
return undefined;
|
|
24
|
-
const hex = value.trim().toLowerCase();
|
|
25
|
-
return hex.length === 4 ? `#${[...hex.slice(1)].map(digit => digit + digit).join('')}` : hex;
|
|
26
|
-
}
|
|
27
|
-
const textOf = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
|
|
28
|
-
/** The brand a manifest declares, each part it leaves out the default's. */
|
|
29
|
-
function brandOf(manifest) {
|
|
30
|
-
return {
|
|
31
|
-
name: textOf(manifest?.name) ?? exports.DEFAULT_BRAND.name,
|
|
32
|
-
tagline: textOf(manifest?.tagline),
|
|
33
|
-
color: hexOf(manifest?.brand_color) ?? hexOf(manifest?.theme_color) ?? exports.DEFAULT_BRAND.color
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text
|
|
38
|
-
* (WCAG 1.4.3) - black below that.
|
|
39
|
-
*/
|
|
40
|
-
function textColorOn(color) {
|
|
41
|
-
const channel = (at) => {
|
|
42
|
-
const value = parseInt(color.slice(at, at + 2), 16) / 255;
|
|
43
|
-
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
44
|
-
};
|
|
45
|
-
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
46
|
-
return 1.05 / (luminance + 0.05) >= 3 ? '#ffffff' : '#000000';
|
|
47
|
-
}
|
|
48
|
-
let read;
|
|
49
|
-
/** The running application's brand; the default when it ships no manifest or one that cannot be read. */
|
|
50
|
-
function appBrand() {
|
|
51
|
-
if (!read) {
|
|
52
|
-
try {
|
|
53
|
-
read = brandOf(JSON.parse(fs_1.default.readFileSync(path_1.default.resolve(env_1.appRootPath, 'assets', 'manifest.json'), 'utf8')));
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
read = exports.DEFAULT_BRAND;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return read;
|
|
60
|
-
}
|
|
61
|
-
//# sourceMappingURL=app-brand.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"app-brand.js","sourceRoot":"","sources":["../../../server/controllers/notice/app-brand.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Whose mail it is - the application's brand name, tagline and colour, read from its web manifest\n * (`assets/manifest.json`), the one identity file every application ships and the image keeps. `name` is the brand\n * name; `tagline` and `brand_color` are the application's own members (a browser passes over members it does not\n * know); with no `brand_color` the manifest's `theme_color` stands in. Read once.\n */\nimport fs from 'fs'\nimport path from 'path'\n\nimport { appRootPath } from '@things-factory/env'\n\nexport type AppBrand = {\n name: string\n tagline?: string\n /** `#rrggbb`. */\n color: string\n}\n\n/** What the notice mail had before an application said its own. */\nexport const DEFAULT_BRAND: AppBrand = { name: 'Operato', color: '#22a6a7' }\n\nconst HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i\n\n/** A 3-digit colour as its 6 digits; anything not a hex colour is not a colour. */\nfunction hexOf(value: unknown): string | undefined {\n if (typeof value !== 'string' || !HEX.test(value.trim())) return undefined\n const hex = value.trim().toLowerCase()\n return hex.length === 4 ? `#${[...hex.slice(1)].map(digit => digit + digit).join('')}` : hex\n}\n\nconst textOf = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)\n\n/** The brand a manifest declares, each part it leaves out the default's. */\nexport function brandOf(manifest: any): AppBrand {\n return {\n name: textOf(manifest?.name) ?? DEFAULT_BRAND.name,\n tagline: textOf(manifest?.tagline),\n color: hexOf(manifest?.brand_color) ?? hexOf(manifest?.theme_color) ?? DEFAULT_BRAND.color\n }\n}\n\n/**\n * The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text\n * (WCAG 1.4.3) - black below that.\n */\nexport function textColorOn(color: string): '#000000' | '#ffffff' {\n const channel = (at: number) => {\n const value = parseInt(color.slice(at, at + 2), 16) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)\n return 1.05 / (luminance + 0.05) >= 3 ? '#ffffff' : '#000000'\n}\n\nlet read: AppBrand | undefined\n\n/** The running application's brand; the default when it ships no manifest or one that cannot be read. */\nexport function appBrand(): AppBrand {\n if (!read) {\n try {\n read = brandOf(JSON.parse(fs.readFileSync(path.resolve(appRootPath, 'assets', 'manifest.json'), 'utf8')))\n } catch {\n read = DEFAULT_BRAND\n }\n }\n return read\n}\n"]}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { type AppBrand } from './app-brand.js';
|
|
2
|
-
import type { NoticeWords } from './notice-channels.js';
|
|
3
|
-
/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */
|
|
4
|
-
export declare function escapeHtml(value: unknown): string;
|
|
5
|
-
/**
|
|
6
|
-
* Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,
|
|
7
|
-
* the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -
|
|
8
|
-
* `javascript:` in any spelling the URL parser reads as one.
|
|
9
|
-
*/
|
|
10
|
-
export declare function safeNoticeHref(href: string | null | undefined, base?: string): string | undefined;
|
|
11
|
-
/**
|
|
12
|
-
* Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or
|
|
13
|
-
* an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a
|
|
14
|
-
* link with its alt text, so a mail does not fetch from wherever its writer pointed.
|
|
15
|
-
*/
|
|
16
|
-
export declare function renderNoticeMarkdown(markdown: string, base?: string): string;
|
|
17
|
-
/**
|
|
18
|
-
* The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender. The head
|
|
19
|
-
* names the application that sends it with its tagline, and the band and button are in its brand colour (`appBrand`).
|
|
20
|
-
* Without a link (a notice sent with no request to take the address from) the button gives way to the note.
|
|
21
|
-
*/
|
|
22
|
-
export declare function noticeEmailForm({ lang, words, link, format, base, brand }: {
|
|
23
|
-
lang: string;
|
|
24
|
-
words: NoticeWords;
|
|
25
|
-
link?: string;
|
|
26
|
-
/** How the body is written - an announcement's is Markdown. */
|
|
27
|
-
format?: 'text' | 'markdown';
|
|
28
|
-
/** The domain's address, under which an app address in a Markdown body is resolved. */
|
|
29
|
-
base?: string;
|
|
30
|
-
/** The application sending it. */
|
|
31
|
-
brand?: AppBrand;
|
|
32
|
-
}): string;
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.escapeHtml = escapeHtml;
|
|
4
|
-
exports.safeNoticeHref = safeNoticeHref;
|
|
5
|
-
exports.renderNoticeMarkdown = renderNoticeMarkdown;
|
|
6
|
-
exports.noticeEmailForm = noticeEmailForm;
|
|
7
|
-
const marked_1 = require("marked");
|
|
8
|
-
const app_brand_js_1 = require("./app-brand.js");
|
|
9
|
-
/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */
|
|
10
|
-
function escapeHtml(value) {
|
|
11
|
-
return String(value ?? '')
|
|
12
|
-
.replace(/&/g, '&')
|
|
13
|
-
.replace(/</g, '<')
|
|
14
|
-
.replace(/>/g, '>')
|
|
15
|
-
.replace(/"/g, '"')
|
|
16
|
-
.replace(/'/g, ''');
|
|
17
|
-
}
|
|
18
|
-
/** The schemes a link in a notice may take; anything else is shown as its text. */
|
|
19
|
-
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
|
20
|
-
const NO_BASE = 'https://no-base.invalid/';
|
|
21
|
-
/**
|
|
22
|
-
* Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,
|
|
23
|
-
* the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -
|
|
24
|
-
* `javascript:` in any spelling the URL parser reads as one.
|
|
25
|
-
*/
|
|
26
|
-
function safeNoticeHref(href, base) {
|
|
27
|
-
const raw = String(href ?? '').trim();
|
|
28
|
-
if (!raw)
|
|
29
|
-
return undefined;
|
|
30
|
-
let url;
|
|
31
|
-
try {
|
|
32
|
-
url = new URL(raw, base || NO_BASE);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
if (!SAFE_PROTOCOLS.has(url.protocol))
|
|
38
|
-
return undefined;
|
|
39
|
-
if (url.origin === new URL(NO_BASE).origin)
|
|
40
|
-
return undefined;
|
|
41
|
-
return url.href;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or
|
|
45
|
-
* an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a
|
|
46
|
-
* link with its alt text, so a mail does not fetch from wherever its writer pointed.
|
|
47
|
-
*/
|
|
48
|
-
function renderNoticeMarkdown(markdown, base) {
|
|
49
|
-
const marked = new marked_1.Marked({
|
|
50
|
-
gfm: true,
|
|
51
|
-
breaks: true,
|
|
52
|
-
async: false,
|
|
53
|
-
renderer: {
|
|
54
|
-
html({ text }) {
|
|
55
|
-
return escapeHtml(text);
|
|
56
|
-
},
|
|
57
|
-
link({ href, title, tokens }) {
|
|
58
|
-
const label = this.parser.parseInline(tokens);
|
|
59
|
-
const safe = safeNoticeHref(href, base);
|
|
60
|
-
return safe ? `<a href="${escapeHtml(safe)}"${title ? ` title="${escapeHtml(title)}"` : ''}>${label}</a>` : label;
|
|
61
|
-
},
|
|
62
|
-
image({ href, text }) {
|
|
63
|
-
const safe = safeNoticeHref(href, base);
|
|
64
|
-
return safe ? `<a href="${escapeHtml(safe)}">${escapeHtml(text || safe)}</a>` : escapeHtml(text);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
return marked.parse(String(markdown ?? ''));
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender. The head
|
|
72
|
-
* names the application that sends it with its tagline, and the band and button are in its brand colour (`appBrand`).
|
|
73
|
-
* Without a link (a notice sent with no request to take the address from) the button gives way to the note.
|
|
74
|
-
*/
|
|
75
|
-
function noticeEmailForm({ lang, words, link, format = 'text', base, brand = app_brand_js_1.DEFAULT_BRAND }) {
|
|
76
|
-
const color = escapeHtml(brand.color);
|
|
77
|
-
const onColor = (0, app_brand_js_1.textColorOn)(brand.color);
|
|
78
|
-
/*
|
|
79
|
-
* Laid out as tables, each colour on its cell as `bgcolor` as well as in its style: mail programs drop a box's
|
|
80
|
-
* margins and backgrounds, and one in dark mode repaints what it was not told is light - so the mail says it is light.
|
|
81
|
-
*/
|
|
82
|
-
const action = link
|
|
83
|
-
? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate">
|
|
84
|
-
<tr>
|
|
85
|
-
<td bgcolor="${color}" style="background-color:${color};border-radius:7px;padding:8px 16px">
|
|
86
|
-
<a href="${escapeHtml(link)}" style="color:${onColor};font-size:18px;font-weight:bold;text-decoration:none"
|
|
87
|
-
><font color="${onColor}">${escapeHtml(words.action)}</font></a
|
|
88
|
-
>
|
|
89
|
-
</td>
|
|
90
|
-
</tr>
|
|
91
|
-
</table>`
|
|
92
|
-
: `<span style="color:#555555">${escapeHtml(words.note)}</span>`;
|
|
93
|
-
return `
|
|
94
|
-
<html lang="${escapeHtml(lang)}">
|
|
95
|
-
<head>
|
|
96
|
-
<meta charset="utf-8" />
|
|
97
|
-
<meta name="color-scheme" content="light" />
|
|
98
|
-
<meta name="supported-color-schemes" content="light" />
|
|
99
|
-
<title>${escapeHtml(words.subject)}</title>
|
|
100
|
-
<meta name="author" content="hatiolab" />
|
|
101
|
-
<meta name="google" content="notranslate"/>
|
|
102
|
-
<style>:root { color-scheme: light; supported-color-schemes: light; }</style>
|
|
103
|
-
</head>
|
|
104
|
-
|
|
105
|
-
<body style="margin:0;padding:0" bgcolor="#f6f6f6">
|
|
106
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f6f6f6" style="background-color:#f6f6f6;border-collapse:collapse">
|
|
107
|
-
<!--header begin-->
|
|
108
|
-
<tr>
|
|
109
|
-
<td bgcolor="#ffffff" style="background-color:#ffffff;border-top:2px solid ${color};padding:10px 20px">
|
|
110
|
-
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
|
111
|
-
<tr>
|
|
112
|
-
<td style="font-size:20px;font-weight:bold;color:#1f2933;white-space:nowrap">${escapeHtml(brand.name)}</td>${brand.tagline
|
|
113
|
-
? `
|
|
114
|
-
<td style="padding-left:12px;font-size:13px;color:#6b7280"> ${escapeHtml(brand.tagline)}</td>`
|
|
115
|
-
: ''}
|
|
116
|
-
</tr>
|
|
117
|
-
</table>
|
|
118
|
-
</td>
|
|
119
|
-
</tr>
|
|
120
|
-
<!--header end-->
|
|
121
|
-
|
|
122
|
-
<!--title begin-->
|
|
123
|
-
<tr>
|
|
124
|
-
<td bgcolor="${color}" style="background-color:${color};padding:14px 20px 12px 60px;color:${onColor};font-size:24px;font-weight:bold"
|
|
125
|
-
><font color="${onColor}">${escapeHtml(words.subject)}</font></td
|
|
126
|
-
>
|
|
127
|
-
</tr>
|
|
128
|
-
<!--title end-->
|
|
129
|
-
|
|
130
|
-
<!--body begin-->
|
|
131
|
-
<tr>
|
|
132
|
-
<td style="padding:10px 20px;line-height:1.5;font-size:16px;color:#1f2933">${format === 'markdown' ? renderNoticeMarkdown(words.body, base) : `<div style="white-space:pre-line">${escapeHtml(words.body)}</div>`}</td>
|
|
133
|
-
</tr>
|
|
134
|
-
<tr>
|
|
135
|
-
<td style="padding:6px 20px 20px 20px;font-size:16px">
|
|
136
|
-
${action}
|
|
137
|
-
</td>
|
|
138
|
-
</tr>
|
|
139
|
-
<!--body end-->
|
|
140
|
-
|
|
141
|
-
<!--footer begin-->
|
|
142
|
-
<tr>
|
|
143
|
-
<td bgcolor="#3d5874" style="background-color:#3d5874;padding:8px 20px 6px 20px;font-size:12px;color:#efefef"
|
|
144
|
-
><font color="#efefef">© Hatio, Lab. Inc. All rights reserved.</font></td
|
|
145
|
-
>
|
|
146
|
-
</tr>
|
|
147
|
-
<!--footer end-->
|
|
148
|
-
</table>
|
|
149
|
-
</body>
|
|
150
|
-
</html>
|
|
151
|
-
`;
|
|
152
|
-
}
|
|
153
|
-
//# sourceMappingURL=notice-email.js.map
|