@things-factory/notification 10.1.106 → 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.
Files changed (27) hide show
  1. package/dist-client/tsconfig.tsbuildinfo +1 -1
  2. package/dist-server/controllers/fcm.d.ts +1 -2
  3. package/dist-server/controllers/fcm.js +5 -1
  4. package/dist-server/controllers/fcm.js.map +1 -1
  5. package/dist-server/controllers/index.d.ts +1 -0
  6. package/dist-server/controllers/index.js +1 -0
  7. package/dist-server/controllers/index.js.map +1 -1
  8. package/dist-server/controllers/receivers.d.ts +11 -0
  9. package/dist-server/controllers/receivers.js +30 -0
  10. package/dist-server/controllers/receivers.js.map +1 -0
  11. package/dist-server/middlewares/notification-middleware.js +3 -3
  12. package/dist-server/middlewares/notification-middleware.js.map +1 -1
  13. package/dist-server/routers/notification-router.js +26 -17
  14. package/dist-server/routers/notification-router.js.map +1 -1
  15. package/dist-server/service/index.d.ts +2 -2
  16. package/dist-server/service/notification/directive-notification.js +19 -7
  17. package/dist-server/service/notification/directive-notification.js.map +1 -1
  18. package/dist-server/service/notification/index.d.ts +1 -1
  19. package/dist-server/service/notification/notification-audience.d.ts +24 -0
  20. package/dist-server/service/notification/notification-audience.js +31 -0
  21. package/dist-server/service/notification/notification-audience.js.map +1 -0
  22. package/dist-server/service/notification/notification-subscription.js +2 -14
  23. package/dist-server/service/notification/notification-subscription.js.map +1 -1
  24. package/dist-server/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +6 -6
  26. package/tests/notification-audience.test.ts +53 -0
  27. package/tests/notify-receivers.test.ts +172 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/notification",
3
- "version": "10.1.106",
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.106",
34
- "@things-factory/i18n-base": "^10.1.106",
35
- "@things-factory/organization": "^10.1.106",
36
- "@things-factory/setting-base": "^10.1.106",
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
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": "8d78c14708420773ec32d18b8dd44c300771c44d"
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
+ })