@things-factory/notification 8.0.87 → 8.0.93
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 +2 -2
- package/dist-server/controllers/fcm.js +80 -102
- package/dist-server/controllers/fcm.js.map +1 -1
- package/dist-server/routers/notification-router.js +4 -4
- package/dist-server/routers/notification-router.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/push-subscription/index.d.ts +3 -0
- package/dist-server/service/push-subscription/index.js +7 -0
- package/dist-server/service/push-subscription/index.js.map +1 -0
- package/dist-server/service/push-subscription/push-subscription.d.ts +17 -0
- package/dist-server/service/push-subscription/push-subscription.js +80 -0
- package/dist-server/service/push-subscription/push-subscription.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/server/controllers/fcm.ts +87 -111
- package/server/routers/notification-router.ts +4 -4
- package/server/service/index.ts +4 -1
- package/server/service/push-subscription/index.ts +4 -0
- package/server/service/push-subscription/push-subscription.ts +70 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/notification",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.93",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@material/web": "^2.0.0",
|
|
31
31
|
"@operato/layout": "^8.0.0",
|
|
32
|
-
"@things-factory/auth-base": "^8.0.
|
|
33
|
-
"@things-factory/codelingua": "^8.0.
|
|
34
|
-
"@things-factory/i18n-base": "^8.0.
|
|
35
|
-
"@things-factory/organization": "^8.0.
|
|
36
|
-
"@things-factory/setting-base": "^8.0.
|
|
37
|
-
"@things-factory/shell": "^8.0.
|
|
32
|
+
"@things-factory/auth-base": "^8.0.93",
|
|
33
|
+
"@things-factory/codelingua": "^8.0.93",
|
|
34
|
+
"@things-factory/i18n-base": "^8.0.93",
|
|
35
|
+
"@things-factory/organization": "^8.0.93",
|
|
36
|
+
"@things-factory/setting-base": "^8.0.93",
|
|
37
|
+
"@things-factory/shell": "^8.0.93",
|
|
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": "2e0834bba65b603e01fb6e966e1add625515d4af"
|
|
44
44
|
}
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { In } from 'typeorm'
|
|
2
2
|
|
|
3
3
|
import { config, logger } from '@things-factory/env'
|
|
4
|
+
import { getRepository } from '@things-factory/shell'
|
|
4
5
|
|
|
5
6
|
import { initializeApp, cert } from 'firebase-admin/app'
|
|
6
7
|
import { getMessaging } from 'firebase-admin/messaging'
|
|
7
8
|
|
|
9
|
+
import { PushSubscription } from '../service/push-subscription/push-subscription'
|
|
10
|
+
|
|
8
11
|
const notificationConfig = config.get('notification') || {}
|
|
9
12
|
const { fcm, vapidKey } = notificationConfig
|
|
10
|
-
const { serviceAccount, appConfig
|
|
13
|
+
const { serviceAccount, appConfig } = fcm || {}
|
|
11
14
|
const { publicKey } = vapidKey || {}
|
|
12
15
|
|
|
16
|
+
// FCM 전송 시 만료/무효로 판정되어 DB에서 정리할 토큰 에러 코드
|
|
17
|
+
const INVALID_TOKEN_CODES = [
|
|
18
|
+
'messaging/registration-token-not-registered',
|
|
19
|
+
'messaging/invalid-registration-token',
|
|
20
|
+
'messaging/invalid-argument'
|
|
21
|
+
]
|
|
22
|
+
|
|
13
23
|
var messaging
|
|
14
24
|
|
|
15
25
|
if (serviceAccount) {
|
|
@@ -51,103 +61,106 @@ export async function sendNotification({ receivers, message }) {
|
|
|
51
61
|
})
|
|
52
62
|
}
|
|
53
63
|
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
// 푸시 구독 등록: FCM 토큰을 DB에 저장 (device-group 대체). (domain, token) 기준 upsert
|
|
65
|
+
export async function register(domain, user, { subscription }) {
|
|
66
|
+
if (!messaging || !subscription || !user || !domain) {
|
|
56
67
|
return false
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
try {
|
|
60
|
-
|
|
71
|
+
const repository = getRepository(PushSubscription)
|
|
72
|
+
const existing = await repository.findOneBy({ domain: { id: domain.id }, token: subscription })
|
|
61
73
|
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
notification_key_name: user.id,
|
|
66
|
-
registration_ids: [subscription]
|
|
67
|
-
})
|
|
74
|
+
if (existing) {
|
|
75
|
+
// 재구독/기기 재할당: 소유 user만 갱신
|
|
76
|
+
await repository.save({ ...existing, user: { id: user.id }, updater: { id: user.id } })
|
|
68
77
|
} else {
|
|
69
|
-
await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
await repository.save({
|
|
79
|
+
domain: { id: domain.id },
|
|
80
|
+
user: { id: user.id },
|
|
81
|
+
token: subscription,
|
|
82
|
+
creator: { id: user.id },
|
|
83
|
+
updater: { id: user.id }
|
|
74
84
|
})
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
return true
|
|
78
88
|
} catch (err) {
|
|
79
|
-
|
|
89
|
+
logger.error('[FCM] register 실패', err)
|
|
90
|
+
return false
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
// 푸시 구독 해제: 해당 토큰 행을 하드 삭제
|
|
95
|
+
export async function unregister(domain, user, { subscription }) {
|
|
96
|
+
if (!messaging || !subscription || !user || !domain) {
|
|
85
97
|
return false
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (notification_key) {
|
|
92
|
-
await postDeviceGroup({
|
|
93
|
-
operation: 'remove',
|
|
94
|
-
notification_key_name: user.id,
|
|
95
|
-
notification_key,
|
|
96
|
-
registration_ids: [subscription]
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
101
|
+
await getRepository(PushSubscription).delete({ domain: { id: domain.id }, token: subscription })
|
|
100
102
|
return true
|
|
101
103
|
} catch (err) {
|
|
104
|
+
logger.error('[FCM] unregister 실패', err)
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 토큰 배열로 FCM HTTP v1 멀티캐스트 전송 + 무효 토큰 DB 정리
|
|
110
|
+
async function sendToTokens(tokens, notification, data) {
|
|
111
|
+
if (!tokens || tokens.length === 0) {
|
|
102
112
|
return
|
|
103
113
|
}
|
|
114
|
+
|
|
115
|
+
// sendEachForMulticast 는 1회 최대 500토큰. 초과 시 청크 (실제 초과 도메인 생기면 대응)
|
|
116
|
+
const response = await messaging.sendEachForMulticast({ notification, data, tokens })
|
|
117
|
+
logger.info(`[FCM] sent ${response.successCount}/${tokens.length}, failed ${response.failureCount}`)
|
|
118
|
+
|
|
119
|
+
if (response.failureCount > 0) {
|
|
120
|
+
const deadTokens = []
|
|
121
|
+
response.responses.forEach((resp, idx) => {
|
|
122
|
+
if (!resp.success) {
|
|
123
|
+
const code = resp.error && resp.error.code
|
|
124
|
+
if (INVALID_TOKEN_CODES.includes(code)) {
|
|
125
|
+
deadTokens.push(tokens[idx])
|
|
126
|
+
} else {
|
|
127
|
+
// 일시적 오류 등 → 토큰 보존, 로그만
|
|
128
|
+
logger.error('[FCM] send failure (token kept):', code, resp.error && resp.error.message)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (deadTokens.length > 0) {
|
|
134
|
+
await getRepository(PushSubscription).delete({ token: In(deadTokens) })
|
|
135
|
+
logger.warn(`[FCM] pruned ${deadTokens.length} invalid token(s)`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return response
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
export async function notify({ receivers, privileges, tokens, topic, title, body, data, image, actions }) {
|
|
107
143
|
if (!messaging) {
|
|
144
|
+
logger.error('[FCM] messaging 미초기화 → 전송 중단 (serviceAccount/FCM_PRIVATE_KEY 확인)')
|
|
108
145
|
return
|
|
109
146
|
}
|
|
110
147
|
|
|
111
|
-
// Caution: non-string attributes are not allowed in FCM payload validation
|
|
112
|
-
|
|
148
|
+
// Caution: non-string attributes are not allowed in FCM payload validation.
|
|
149
|
+
// image은 빈 문자열이면 HTTP v1이 "invalid URL"로 거부하므로 값이 있을 때만 포함
|
|
150
|
+
var notification: { title: string; body: string; image?: string } = {
|
|
113
151
|
title: title || '',
|
|
114
|
-
body: body || ''
|
|
115
|
-
|
|
152
|
+
body: body || ''
|
|
153
|
+
}
|
|
154
|
+
if (image) {
|
|
155
|
+
notification.image = image
|
|
116
156
|
}
|
|
117
157
|
|
|
158
|
+
// 1) 명시적 토큰 전송
|
|
118
159
|
if (tokens && tokens instanceof Array && tokens.length > 0) {
|
|
119
|
-
|
|
120
|
-
const response = await messaging.sendMulticast({
|
|
121
|
-
notification,
|
|
122
|
-
data,
|
|
123
|
-
tokens
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
if (response.failureCount > 0) {
|
|
127
|
-
const failedTokens = []
|
|
128
|
-
response.responses.forEach((resp, idx) => {
|
|
129
|
-
if (!resp.success) {
|
|
130
|
-
failedTokens.push(tokens[idx])
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
logger.error('List of tokens that caused failures: ' + failedTokens)
|
|
134
|
-
}
|
|
135
|
-
} else {
|
|
136
|
-
const token = tokens[0]
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
const response = await messaging.send({
|
|
140
|
-
notification,
|
|
141
|
-
data,
|
|
142
|
-
token
|
|
143
|
-
})
|
|
144
|
-
logger.log('Successfully sent message:', response)
|
|
145
|
-
} catch (err) {
|
|
146
|
-
logger.error('Error sending message:', err)
|
|
147
|
-
}
|
|
148
|
-
}
|
|
160
|
+
await sendToTokens(tokens, notification, data)
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
// 2) 토픽 전송
|
|
151
164
|
if (topic) {
|
|
152
165
|
try {
|
|
153
166
|
const response = await messaging.send({
|
|
@@ -161,54 +174,17 @@ export async function notify({ receivers, privileges, tokens, topic, title, body
|
|
|
161
174
|
}
|
|
162
175
|
}
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
notification,
|
|
171
|
-
data
|
|
172
|
-
})
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function postDeviceGroup(body) {
|
|
179
|
-
var response = await fetch('https://fcm.googleapis.com/fcm/notification', {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
headers: {
|
|
182
|
-
'Content-Type': 'application/json',
|
|
183
|
-
Authorization: `key=${serverKey}`,
|
|
184
|
-
project_id: appConfig.messagingSenderId
|
|
185
|
-
},
|
|
186
|
-
body: JSON.stringify(body)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
if (response.ok) {
|
|
190
|
-
const { notification_key } = await response.json()
|
|
191
|
-
return notification_key
|
|
192
|
-
} else {
|
|
193
|
-
console.error('postDeviceGroup-notok', response.status, await response.text())
|
|
194
|
-
}
|
|
195
|
-
}
|
|
177
|
+
// 3) 수신자(user.id 배열) → 저장된 구독 토큰 조회 후 전송
|
|
178
|
+
if (receivers && receivers instanceof Array && receivers.length > 0) {
|
|
179
|
+
const rows = await getRepository(PushSubscription).find({
|
|
180
|
+
where: { user: { id: In(receivers) } }
|
|
181
|
+
})
|
|
182
|
+
const receiverTokens = [...new Set(rows.map(r => r.token))]
|
|
196
183
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
method: 'GET',
|
|
202
|
-
headers: {
|
|
203
|
-
'Content-Type': 'application/json',
|
|
204
|
-
Authorization: `key=${serverKey}`,
|
|
205
|
-
project_id: appConfig.messagingSenderId
|
|
206
|
-
}
|
|
184
|
+
if (receiverTokens.length === 0) {
|
|
185
|
+
logger.warn('[FCM] no push subscriptions for receivers:', JSON.stringify(receivers))
|
|
186
|
+
} else {
|
|
187
|
+
await sendToTokens(receiverTokens, notification, data)
|
|
207
188
|
}
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
if (response.ok) {
|
|
211
|
-
const { notification_key } = await response.json()
|
|
212
|
-
return notification_key
|
|
213
189
|
}
|
|
214
190
|
}
|
|
@@ -20,16 +20,16 @@ notificationRouter.get('/config', async (context, next) => {
|
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
notificationRouter.post('/register', async (context, next) => {
|
|
23
|
-
const { user } = context.state
|
|
23
|
+
const { user, domain } = context.state
|
|
24
24
|
const { body } = context.request
|
|
25
|
-
context.body = await register(user, body)
|
|
25
|
+
context.body = await register(domain, user, body)
|
|
26
26
|
context.status = 200
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
notificationRouter.post('/unregister', async (context, next) => {
|
|
30
|
-
const { user } = context.state
|
|
30
|
+
const { user, domain } = context.state
|
|
31
31
|
const { body } = context.request
|
|
32
|
-
context.body = await unregister(user, body)
|
|
32
|
+
context.body = await unregister(domain, user, body)
|
|
33
33
|
context.status = 200
|
|
34
34
|
})
|
|
35
35
|
|
package/server/service/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* EXPORT ENTITY TYPES */
|
|
2
2
|
export * from './notification-rule/notification-rule'
|
|
3
3
|
export * from './notification/notification'
|
|
4
|
+
export * from './push-subscription/push-subscription'
|
|
4
5
|
|
|
5
6
|
/* IMPORT ENTITIES AND RESOLVERS */
|
|
6
7
|
import {
|
|
@@ -14,11 +15,13 @@ import {
|
|
|
14
15
|
resolvers as NotificationResolvers,
|
|
15
16
|
directives as NotificationDirectives
|
|
16
17
|
} from './notification'
|
|
18
|
+
import { entities as PushSubscriptionEntities } from './push-subscription'
|
|
17
19
|
|
|
18
20
|
export const entities = [
|
|
19
21
|
/* ENTITIES */
|
|
20
22
|
...NotificationRuleEntities,
|
|
21
|
-
...NotificationEntities
|
|
23
|
+
...NotificationEntities,
|
|
24
|
+
...PushSubscriptionEntities
|
|
22
25
|
]
|
|
23
26
|
|
|
24
27
|
export const subscribers = [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateDateColumn,
|
|
3
|
+
UpdateDateColumn,
|
|
4
|
+
DeleteDateColumn,
|
|
5
|
+
Entity,
|
|
6
|
+
Index,
|
|
7
|
+
Column,
|
|
8
|
+
RelationId,
|
|
9
|
+
ManyToOne,
|
|
10
|
+
PrimaryGeneratedColumn
|
|
11
|
+
} from 'typeorm'
|
|
12
|
+
import { ObjectType, Field, ID } from 'type-graphql'
|
|
13
|
+
|
|
14
|
+
import { Domain } from '@things-factory/shell'
|
|
15
|
+
import { User } from '@things-factory/auth-base'
|
|
16
|
+
|
|
17
|
+
// FCM 웹 푸시 구독 토큰 저장 엔티티 (레거시 device-group 대체, HTTP v1 토큰 직접 전송용)
|
|
18
|
+
@Entity()
|
|
19
|
+
@Index('ix_push_subscription_0', (ps: PushSubscription) => [ps.domain, ps.token], { unique: true })
|
|
20
|
+
@Index('ix_push_subscription_1', (ps: PushSubscription) => [ps.user], { unique: false })
|
|
21
|
+
@ObjectType({ description: 'Entity for PushSubscription (FCM registration token)' })
|
|
22
|
+
export class PushSubscription {
|
|
23
|
+
@PrimaryGeneratedColumn('uuid')
|
|
24
|
+
@Field(type => ID)
|
|
25
|
+
readonly id: string
|
|
26
|
+
|
|
27
|
+
@ManyToOne(type => Domain)
|
|
28
|
+
@Field(type => Domain)
|
|
29
|
+
domain?: Domain
|
|
30
|
+
|
|
31
|
+
@RelationId((ps: PushSubscription) => ps.domain)
|
|
32
|
+
domainId?: string
|
|
33
|
+
|
|
34
|
+
@ManyToOne(type => User, { nullable: true })
|
|
35
|
+
@Field(type => User, { nullable: true })
|
|
36
|
+
user?: User
|
|
37
|
+
|
|
38
|
+
@RelationId((ps: PushSubscription) => ps.user)
|
|
39
|
+
userId?: string
|
|
40
|
+
|
|
41
|
+
@Column()
|
|
42
|
+
@Field()
|
|
43
|
+
token: string // FCM registration token
|
|
44
|
+
|
|
45
|
+
@CreateDateColumn()
|
|
46
|
+
@Field({ nullable: true })
|
|
47
|
+
createdAt?: Date
|
|
48
|
+
|
|
49
|
+
@UpdateDateColumn()
|
|
50
|
+
@Field({ nullable: true })
|
|
51
|
+
updatedAt?: Date
|
|
52
|
+
|
|
53
|
+
@DeleteDateColumn()
|
|
54
|
+
@Field({ nullable: true })
|
|
55
|
+
deletedAt?: Date
|
|
56
|
+
|
|
57
|
+
@ManyToOne(type => User, { nullable: true })
|
|
58
|
+
@Field(type => User, { nullable: true })
|
|
59
|
+
creator?: User
|
|
60
|
+
|
|
61
|
+
@RelationId((ps: PushSubscription) => ps.creator)
|
|
62
|
+
creatorId?: string
|
|
63
|
+
|
|
64
|
+
@ManyToOne(type => User, { nullable: true })
|
|
65
|
+
@Field(type => User, { nullable: true })
|
|
66
|
+
updater?: User
|
|
67
|
+
|
|
68
|
+
@RelationId((ps: PushSubscription) => ps.updater)
|
|
69
|
+
updaterId?: string
|
|
70
|
+
}
|