@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/notification",
3
- "version": "8.0.87",
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.87",
33
- "@things-factory/codelingua": "^8.0.87",
34
- "@things-factory/i18n-base": "^8.0.87",
35
- "@things-factory/organization": "^8.0.87",
36
- "@things-factory/setting-base": "^8.0.87",
37
- "@things-factory/shell": "^8.0.86",
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": "1804f2446f618e0e5a6978d35130d17e0c22375d"
43
+ "gitHead": "2e0834bba65b603e01fb6e966e1add625515d4af"
44
44
  }
@@ -1,15 +1,25 @@
1
- import fetch from 'node-fetch'
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, serverKey } = fcm || {}
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
- export async function register(user, { subscription }) {
55
- if (!messaging || !subscription || !user) {
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
- var notification_key = await getDeviceGroup(user.id)
71
+ const repository = getRepository(PushSubscription)
72
+ const existing = await repository.findOneBy({ domain: { id: domain.id }, token: subscription })
61
73
 
62
- if (!notification_key) {
63
- await postDeviceGroup({
64
- operation: 'create',
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 postDeviceGroup({
70
- operation: 'add',
71
- notification_key_name: user.id,
72
- notification_key,
73
- registration_ids: [subscription]
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
- return
89
+ logger.error('[FCM] register 실패', err)
90
+ return false
80
91
  }
81
92
  }
82
93
 
83
- export async function unregister(user, { subscription }) {
84
- if (!messaging || !subscription || !user) {
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
- var notification_key = await getDeviceGroup(user.id)
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
- var notification = {
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
- image: image || ''
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
- if (tokens.length > 1) {
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
- if (receivers && receivers instanceof Array) {
165
- for (let receiver of receivers) {
166
- const notification_key = await getDeviceGroup(receiver)
167
-
168
- if (notification_key) {
169
- await messaging.sendToDeviceGroup(notification_key, {
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
- async function getDeviceGroup(notificationKeyName) {
198
- const response = await fetch(
199
- `https://fcm.googleapis.com/fcm/notification?notification_key_name=${notificationKeyName}`,
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
 
@@ -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,4 @@
1
+ import { PushSubscription } from './push-subscription'
2
+
3
+ export const entities = [PushSubscription]
4
+ export const resolvers = []
@@ -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
+ }