@things-factory/notification 10.1.105 → 10.1.107
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 +1 -2
- package/dist-server/controllers/fcm.js +5 -1
- package/dist-server/controllers/fcm.js.map +1 -1
- package/dist-server/controllers/index.d.ts +1 -0
- package/dist-server/controllers/index.js +1 -0
- package/dist-server/controllers/index.js.map +1 -1
- package/dist-server/controllers/receivers.d.ts +11 -0
- package/dist-server/controllers/receivers.js +30 -0
- package/dist-server/controllers/receivers.js.map +1 -0
- package/dist-server/middlewares/notification-middleware.js +3 -3
- package/dist-server/middlewares/notification-middleware.js.map +1 -1
- package/dist-server/routers/notification-router.js +26 -17
- package/dist-server/routers/notification-router.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/notification/directive-notification.js +19 -7
- package/dist-server/service/notification/directive-notification.js.map +1 -1
- package/dist-server/service/notification/index.d.ts +1 -1
- package/dist-server/service/notification/notification-audience.d.ts +24 -0
- package/dist-server/service/notification/notification-audience.js +31 -0
- package/dist-server/service/notification/notification-audience.js.map +1 -0
- package/dist-server/service/notification/notification-subscription.js +2 -14
- package/dist-server/service/notification/notification-subscription.js.map +1 -1
- package/dist-server/service/notification-rule/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/tests/notification-audience.test.ts +53 -0
- package/tests/notify-receivers.test.ts +172 -0
- package/things-factory.config.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/notification",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.107",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -30,15 +30,15 @@
|
|
|
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/i18n-base": "^10.1.
|
|
35
|
-
"@things-factory/organization": "^10.1.
|
|
36
|
-
"@things-factory/setting-base": "^10.1.
|
|
37
|
-
"@things-factory/shell": "^10.1.
|
|
33
|
+
"@things-factory/auth-base": "^10.1.107",
|
|
34
|
+
"@things-factory/i18n-base": "^10.1.107",
|
|
35
|
+
"@things-factory/organization": "^10.1.107",
|
|
36
|
+
"@things-factory/setting-base": "^10.1.107",
|
|
37
|
+
"@things-factory/shell": "^10.1.106",
|
|
38
38
|
"clipboard": "^2.0.6",
|
|
39
39
|
"firebase": "^9.14.0",
|
|
40
40
|
"firebase-admin": "^11.3.0",
|
|
41
41
|
"google": "^2.1.0"
|
|
42
42
|
},
|
|
43
|
-
"gitHead": "
|
|
43
|
+
"gitHead": "65a3d5889e687d8122a65f029b926b1a4769d1c0"
|
|
44
44
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* An in-app notification addressed to people reaches only them; one addressed to nobody reaches the domain.
|
|
3
|
+
*
|
|
4
|
+
* Until 2026-10-02 the notifier dropped `receivers` on the way to pubsub and the subscription filtered by
|
|
5
|
+
* domain and subject only: a data archive's download link, sent to its requester, went to every subscriber
|
|
6
|
+
* of the domain.
|
|
7
|
+
*/
|
|
8
|
+
import { pubsub } from '@things-factory/shell'
|
|
9
|
+
|
|
10
|
+
import { notificationMiddleware } from '../dist-server/middlewares/notification-middleware.js'
|
|
11
|
+
import { reaches } from '../dist-server/service/notification/notification-audience.js'
|
|
12
|
+
|
|
13
|
+
const home = { subdomain: 'home' }
|
|
14
|
+
const alice = { id: 'alice', domains: [home] }
|
|
15
|
+
const bob = { id: 'bob', domains: [home] }
|
|
16
|
+
|
|
17
|
+
describe('reaches', () => {
|
|
18
|
+
it('reaches only the receivers when there are any', () => {
|
|
19
|
+
const notification = { domain: home, receivers: ['alice'] }
|
|
20
|
+
expect(reaches(notification, { domain: home, user: alice })).toBe(true)
|
|
21
|
+
expect(reaches(notification, { domain: home, user: bob })).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('reaches everyone in the domain when nobody is named', () => {
|
|
25
|
+
expect(reaches({ domain: home }, { domain: home, user: bob })).toBe(true)
|
|
26
|
+
expect(reaches({ domain: home, receivers: [] }, { domain: home, user: bob })).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not cross domains', () => {
|
|
30
|
+
expect(reaches({ domain: { subdomain: 'other' } }, { domain: home, user: alice })).toBe(false)
|
|
31
|
+
expect(reaches({ domain: { subdomain: 'other' } }, { user: alice })).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('still filters by subject', () => {
|
|
35
|
+
expect(reaches({ domain: home, subject: 'warn' }, { domain: home, user: alice }, ['info'])).toBe(false)
|
|
36
|
+
expect(reaches({ domain: home, subject: 'info' }, { domain: home, user: alice }, ['info'])).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('notifier', () => {
|
|
41
|
+
it('publishes the receivers of an in-app notification', async () => {
|
|
42
|
+
const publish = jest.spyOn(pubsub, 'publish').mockImplementation(async () => {})
|
|
43
|
+
const context: any = { state: { domain: home, user: alice } }
|
|
44
|
+
await notificationMiddleware(context, async () => {})
|
|
45
|
+
|
|
46
|
+
await context.state.notify({ receivers: ['alice'], mode: 'in-app', title: 'ready' })
|
|
47
|
+
|
|
48
|
+
expect(publish).toHaveBeenCalledWith('notification', {
|
|
49
|
+
notification: expect.objectContaining({ domain: home, receivers: ['alice'] })
|
|
50
|
+
})
|
|
51
|
+
publish.mockRestore()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `POST /notification/notify` reaches members of the sending domain only, and only someone allowed to
|
|
3
|
+
* notify others may name anyone but themself.
|
|
4
|
+
*
|
|
5
|
+
* Until 2026-10-02 a receiver was looked up by e-mail among every user of every tenant, and the route
|
|
6
|
+
* asked for nothing: any signed-in member could push a notification to anyone.
|
|
7
|
+
*
|
|
8
|
+
* The `@notification` directive sends what it declares, to members of the domain.
|
|
9
|
+
*
|
|
10
|
+
* Reads dist-server (entities need tsc's decorator metadata) - run the package's build:server first.
|
|
11
|
+
*/
|
|
12
|
+
import { DataSource } from 'typeorm'
|
|
13
|
+
|
|
14
|
+
import { NamingStrategy, addDataSource, entities as shellEntities, Domain } from '@things-factory/shell'
|
|
15
|
+
import { Privilege, Role, User, entities as authEntities } from '@things-factory/auth-base'
|
|
16
|
+
|
|
17
|
+
import { graphql } from 'graphql'
|
|
18
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
19
|
+
|
|
20
|
+
import { notificationRouter } from '../dist-server/routers/notification-router.js'
|
|
21
|
+
import {
|
|
22
|
+
directiveNotification,
|
|
23
|
+
notificationDirectiveTypeDefs
|
|
24
|
+
} from '../dist-server/service/notification/directive-notification.js'
|
|
25
|
+
import { domainMemberIdsByEmail } from '../dist-server/controllers/receivers.js'
|
|
26
|
+
|
|
27
|
+
let dataSource: DataSource
|
|
28
|
+
let home: Domain
|
|
29
|
+
let other: Domain
|
|
30
|
+
let sender: User
|
|
31
|
+
let colleague: User
|
|
32
|
+
let stranger: User
|
|
33
|
+
|
|
34
|
+
const handler = (() => {
|
|
35
|
+
const layer = (notificationRouter as any).stack.find(
|
|
36
|
+
(layer: any) => layer.path === '/notification/notify' && layer.methods.includes('POST')
|
|
37
|
+
)
|
|
38
|
+
return layer.stack[layer.stack.length - 1]
|
|
39
|
+
})()
|
|
40
|
+
|
|
41
|
+
async function post(from: User, receivers: string) {
|
|
42
|
+
const sent: any[] = []
|
|
43
|
+
const context: any = {
|
|
44
|
+
state: { user: from, domain: home, notify: async (message: any) => sent.push(message) },
|
|
45
|
+
request: { body: { receivers, title: 't', body: 'b' } }
|
|
46
|
+
}
|
|
47
|
+
await handler(context, async () => {})
|
|
48
|
+
return { status: context.status ?? 200, sent }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function grant(user: User, category: string, name: string) {
|
|
52
|
+
const privilege = await dataSource.getRepository(Privilege).save({ name, category } as any)
|
|
53
|
+
const role = await dataSource.getRepository(Role).save({ name: `${category}-${name}`, domain: home, privileges: [privilege] } as any)
|
|
54
|
+
user.roles = [role]
|
|
55
|
+
await dataSource.getRepository(User).save(user)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
dataSource = new DataSource({
|
|
60
|
+
type: 'sqlite',
|
|
61
|
+
database: ':memory:',
|
|
62
|
+
synchronize: true,
|
|
63
|
+
dropSchema: true,
|
|
64
|
+
namingStrategy: new NamingStrategy(),
|
|
65
|
+
entities: [...shellEntities, ...authEntities],
|
|
66
|
+
logging: false
|
|
67
|
+
})
|
|
68
|
+
await dataSource.initialize()
|
|
69
|
+
addDataSource('default', dataSource)
|
|
70
|
+
;(process as any).domainOwnerGranted = async () => false
|
|
71
|
+
;(process as any).superUserGranted = async () => false
|
|
72
|
+
|
|
73
|
+
const domains = dataSource.getRepository(Domain)
|
|
74
|
+
home = await domains.save({ name: 'home', subdomain: 'home' } as any)
|
|
75
|
+
other = await domains.save({ name: 'other', subdomain: 'other' } as any)
|
|
76
|
+
|
|
77
|
+
const users = dataSource.getRepository(User)
|
|
78
|
+
const make = (name: string, domains: Domain[]) =>
|
|
79
|
+
users.save({ name, username: name, email: `${name}@test.com`, domains } as any)
|
|
80
|
+
sender = await make('sender', [home])
|
|
81
|
+
colleague = await make('colleague', [home])
|
|
82
|
+
stranger = await make('stranger', [other])
|
|
83
|
+
|
|
84
|
+
await grant(sender, 'notification-rule', 'mutation')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await dataSource?.destroy()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('receivers by e-mail', () => {
|
|
92
|
+
it('finds members of the domain, without case', async () => {
|
|
93
|
+
expect(await domainMemberIdsByEmail(home, ['COLLEAGUE@test.com', ' sender@test.com '])).toEqual(
|
|
94
|
+
expect.arrayContaining([colleague.id, sender.id])
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not find a user of another domain', async () => {
|
|
99
|
+
expect(await domainMemberIdsByEmail(home, ['stranger@test.com'])).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('POST /notify', () => {
|
|
104
|
+
it('sends to the sender when nobody is named', async () => {
|
|
105
|
+
expect(await post(sender, '')).toEqual({ status: 200, sent: [expect.objectContaining({ receivers: [sender.id] })] })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does not reach a user of another domain', async () => {
|
|
109
|
+
const { status, sent } = await post(sender, 'stranger@test.com')
|
|
110
|
+
expect(status).toBe(404)
|
|
111
|
+
expect(sent).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('drops the stranger and keeps the colleague when both are named', async () => {
|
|
115
|
+
const { sent } = await post(sender, 'stranger@test.com, colleague@test.com')
|
|
116
|
+
expect(sent.map(message => message.receivers)).toEqual([[colleague.id]])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('refuses to name a colleague without notification-rule:mutation', async () => {
|
|
120
|
+
const { status, sent } = await post(colleague, 'sender@test.com')
|
|
121
|
+
expect(status).toBe(403)
|
|
122
|
+
expect(sent).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('lets anyone name only themself', async () => {
|
|
126
|
+
const { status, sent } = await post(colleague, 'colleague@test.com')
|
|
127
|
+
expect(status).toBe(200)
|
|
128
|
+
expect(sent.map(message => message.receivers)).toEqual([[colleague.id]])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('@notification directive', () => {
|
|
134
|
+
const schema = directiveNotification(
|
|
135
|
+
makeExecutableSchema({
|
|
136
|
+
typeDefs: [
|
|
137
|
+
notificationDirectiveTypeDefs,
|
|
138
|
+
`type Query {
|
|
139
|
+
mine: String @notification(title: "done", body: "it is done", url: "/x", mode: "in-app")
|
|
140
|
+
theirs: String @notification(title: "done", receivers: "colleague@test.com, stranger@test.com")
|
|
141
|
+
outsiders: String @notification(title: "done", receivers: "stranger@test.com")
|
|
142
|
+
}`
|
|
143
|
+
],
|
|
144
|
+
resolvers: { Query: { mine: () => 'ok', theirs: () => 'ok', outsiders: () => 'ok' } }
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async function run(field: string) {
|
|
149
|
+
const sent: any[] = []
|
|
150
|
+
const result = await graphql({
|
|
151
|
+
schema,
|
|
152
|
+
source: `{ ${field} }`,
|
|
153
|
+
contextValue: { state: { user: sender, domain: home, notify: async (message: any) => sent.push(message) } }
|
|
154
|
+
})
|
|
155
|
+
expect(result.errors).toBeUndefined()
|
|
156
|
+
return sent
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('sends its own arguments to the caller when nobody is named', async () => {
|
|
160
|
+
expect(await run('mine')).toEqual([
|
|
161
|
+
expect.objectContaining({ title: 'done', body: 'it is done', url: '/x', mode: 'in-app', receivers: [sender.id] })
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('reaches the named members of the domain only', async () => {
|
|
166
|
+
expect((await run('theirs')).map(message => message.receivers)).toEqual([[colleague.id]])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('sends nothing when nobody named is a member', async () => {
|
|
170
|
+
expect(await run('outsiders')).toEqual([])
|
|
171
|
+
})
|
|
172
|
+
})
|
package/things-factory.config.js
CHANGED
|
@@ -4,8 +4,8 @@ import route from './dist-client/route'
|
|
|
4
4
|
export default {
|
|
5
5
|
route,
|
|
6
6
|
routes: [
|
|
7
|
-
{ tagname: 'notification-rule-list', page: 'notification-rule-list' },
|
|
8
|
-
{ tagname: 'notification-list', page: 'notification-list' }
|
|
7
|
+
{ tagname: 'notification-rule-list-page', page: 'notification-rule-list' },
|
|
8
|
+
{ tagname: 'notification-list-page', page: 'notification-list' }
|
|
9
9
|
],
|
|
10
10
|
bootstrap
|
|
11
11
|
}
|