-
Notifications
You must be signed in to change notification settings - Fork 0
[#130] Firebase로 dueDate가 하루 남은 Todo에 대해서 해당 계정으로 푸시 알람을 보내고 저장하는 기능을 구현한다 #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b0c1104
3542b25
686ef37
e204c5f
c33c2a4
653ebdd
9e3c94e
720726e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,18 +22,17 @@ final class UserService { | |||||||||||||||||||||||||||||||||||||||||
| logger.error("User not authenticated") | ||||||||||||||||||||||||||||||||||||||||||
| throw AuthError.notAuthenticated | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| let userRef = store.document("users/\(user.uid)") | ||||||||||||||||||||||||||||||||||||||||||
| let infoRef = store.document("users/\(user.uid)/userData/info") | ||||||||||||||||||||||||||||||||||||||||||
| let tokensRef = store.document("users/\(user.uid)/userData/tokens") | ||||||||||||||||||||||||||||||||||||||||||
| let settingsRef = store.document("users/\(user.uid)/userData/settings") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 사용자 기본 정보 | ||||||||||||||||||||||||||||||||||||||||||
| var userField: [String: Any] = [ | ||||||||||||||||||||||||||||||||||||||||||
| "statusMsg": "", | ||||||||||||||||||||||||||||||||||||||||||
| "lastLogin": FieldValue.serverTimestamp() | ||||||||||||||||||||||||||||||||||||||||||
| "currentProvider": response.providerID | ||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
32
to
34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이전 코드에서는
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| userField["currentProvider"] = response.providerID | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 공급자 이슈로 인한 nil 방지 | ||||||||||||||||||||||||||||||||||||||||||
| if let email = user.email { | ||||||||||||||||||||||||||||||||||||||||||
| userField["email"] = email | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -48,22 +47,45 @@ final class UserService { | |||||||||||||||||||||||||||||||||||||||||
| user.displayName != nil && user.displayName != "" { | ||||||||||||||||||||||||||||||||||||||||||
| userField["appleName"] = user.displayName | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try await infoRef.setData(userField, merge: true) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| let userDocument = try await userRef.getDocument() | ||||||||||||||||||||||||||||||||||||||||||
| if !userDocument.exists { | ||||||||||||||||||||||||||||||||||||||||||
| userField["statusMsg"] = "" | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| var settingField = ["fcmToken": response.fcmToken] | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 깃헙 로그인 시 추가 정보 저장 | ||||||||||||||||||||||||||||||||||||||||||
| if response.providerID == "github.com", let accessToken = response.accessToken { | ||||||||||||||||||||||||||||||||||||||||||
| settingField["githubAccessToken"] = accessToken | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try await tokensRef.setData(settingField, merge: true) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try await settingsRef.setData([ | ||||||||||||||||||||||||||||||||||||||||||
| "allowPushNotification": true, | ||||||||||||||||||||||||||||||||||||||||||
| "pushNotificationHour": 9, | ||||||||||||||||||||||||||||||||||||||||||
| "pushNotificationMinute": 0], merge: true) | ||||||||||||||||||||||||||||||||||||||||||
| // Reference to capture ~ in concurrently-executing code; Swift 6 lang mode의 경고 해결 | ||||||||||||||||||||||||||||||||||||||||||
| let userFieldSnapshot = userField | ||||||||||||||||||||||||||||||||||||||||||
| let settingFieldSnapshot = settingField | ||||||||||||||||||||||||||||||||||||||||||
| // ----------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async let userUpdate: Void = userRef.setData( | ||||||||||||||||||||||||||||||||||||||||||
| ["updatedAt": FieldValue.serverTimestamp()], | ||||||||||||||||||||||||||||||||||||||||||
| merge: true | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| async let infoUpdate: Void = infoRef.setData(userFieldSnapshot, merge: true) | ||||||||||||||||||||||||||||||||||||||||||
| async let tokensUpdate: Void = tokensRef.setData(settingFieldSnapshot, merge: true) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| let settingsDocument = try await settingsRef.getDocument() | ||||||||||||||||||||||||||||||||||||||||||
| var settingsField: [String: Any] = [ | ||||||||||||||||||||||||||||||||||||||||||
| "timeZone": TimeZone.autoupdatingCurrent.identifier | ||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||
| if !settingsDocument.exists { | ||||||||||||||||||||||||||||||||||||||||||
| settingsField["allowPushNotification"] = true | ||||||||||||||||||||||||||||||||||||||||||
| settingsField["pushNotificationHour"] = 9 | ||||||||||||||||||||||||||||||||||||||||||
| settingsField["pushNotificationMinute"] = 0 | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+75
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| let settingsFieldSnapshot = settingsField | ||||||||||||||||||||||||||||||||||||||||||
| async let settingsUpdate: Void = settingsRef.setData(settingsFieldSnapshot, merge: true) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| _ = try await (userUpdate, infoUpdate, tokensUpdate, settingsUpdate) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| logger.info("Successfully upserted user: \(user.uid)") | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,44 +2,162 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; | |
| import * as admin from "firebase-admin"; | ||
| import * as logger from "firebase-functions/logger"; | ||
|
|
||
| type TaskPayload = { | ||
| userId: string; | ||
| todoId: string; | ||
| todoKind: string; | ||
| dueDateKey: string; | ||
| title: string; | ||
| body: string; | ||
| }; | ||
|
|
||
| type FirestoreErrorLike = { | ||
| code?: unknown; | ||
| }; | ||
|
|
||
| // Cloud Tasks에 의해 트리거되는 함수 | ||
| export const sendPushNotification = onTaskDispatched({ | ||
| export const sendPushNotification = onTaskDispatched({ | ||
| region: "asia-northeast3", | ||
| retryConfig: { maxAttempts: 1, minBackoffSeconds: 5 }, | ||
| rateLimits: { maxDispatchesPerSecond: 500 }, | ||
| retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, | ||
| rateLimits: { maxDispatchesPerSecond: 200 }, | ||
| }, | ||
| async (req) => { | ||
| const { userId, title, body } = req.data; // 예약 시 보냈던 데이터 | ||
| logger.info(`[${userId}]에게 알림 발송 작업을 시작합니다: ${title}`); | ||
| const taskId = req.data?.taskId; | ||
| if (!isValidTaskId(taskId)) { | ||
| logger.warn("유효하지 않은 푸시 알림 payload", req.data); | ||
| return; | ||
| } | ||
|
|
||
| const taskDocRef = admin.firestore().collection("notificationTasks").doc(taskId); | ||
| try { | ||
| const taskDoc = await taskDocRef.get(); | ||
| if (!taskDoc.exists) { | ||
| logger.warn("notificationTask 문서를 찾을 수 없습니다.", { taskId }); | ||
| return; | ||
| } | ||
|
|
||
| const parsed = parseTaskPayload(taskDoc.data()); | ||
| if (!parsed) { | ||
| logger.warn("notificationTask 문서 형식이 올바르지 않습니다.", { taskId }); | ||
| return; | ||
| } | ||
| const { userId, todoId, todoKind, dueDateKey, title, body } = parsed; | ||
|
|
||
| const settingsDoc = await admin.firestore().doc(`users/${userId}/userData/settings`).get(); | ||
| const allowPushNotification = settingsDoc.data()?.allowPushNotification ?? true; | ||
| if (!allowPushNotification) { | ||
| return; | ||
| } | ||
|
|
||
| const notificationDocId = `${todoId}_${dueDateKey}`; | ||
| const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${notificationDocId}`); | ||
|
Comment on lines
+46
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| const notificationData = { | ||
| title: "Todo 알림", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| body, | ||
| receivedAt: admin.firestore.FieldValue.serverTimestamp(), | ||
| isRead: false, | ||
| todoID: todoId, | ||
| todoKind: todoKind | ||
| }; | ||
| try { | ||
| await notificationDocRef.create(notificationData); | ||
| } catch (error) { | ||
| if (isAlreadyExistsError(error)) { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
|
|
||
| // 1. 사용자 FCM 토큰 가져오기 | ||
| const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get(); | ||
| const fcmToken = tokenDoc.data()?.fcmToken; | ||
|
|
||
| if (!fcmToken) { | ||
| logger.warn(`사용자 ${userId}의 fcmToken이 없어 알림을 보낼 수 없습니다.`); | ||
| logger.warn(`사용자 ${userId}의 fcmToken이 없어 푸시 발송은 건너뜁니다. Firestore에는 기록했습니다.`); | ||
| return; | ||
| } | ||
|
|
||
| // 2. 알림 발송 및 Firestore에 기록 | ||
| // 2. 푸시 알림 발송 | ||
| const message = { | ||
| notification: { title, body }, | ||
| data: { | ||
| todoID: todoId, | ||
| todoId: todoId, | ||
| todoKind: todoKind | ||
| }, | ||
| apns: { payload: { aps: { sound: "default" } } }, | ||
| token: fcmToken, | ||
| }; | ||
| await admin.messaging().send(message); | ||
|
|
||
| const notificationData = { | ||
| title, body, sentAt: admin.firestore.FieldValue.serverTimestamp(), isRead: false, type: 'reminder' | ||
| }; | ||
| await admin.firestore().collection(`users/${userId}/notifications`).add(notificationData); | ||
|
|
||
| logger.info(`[${userId}]에게 알림을 성공적으로 보내고 저장했습니다.`); | ||
| try { | ||
| await admin.messaging().send(message); | ||
| } catch (sendError) { | ||
| logger.warn(`[${userId}] 푸시 발송 실패. Firestore 기록은 유지됩니다.`, sendError); | ||
| return; | ||
| } | ||
|
|
||
| } catch (error) { | ||
| logger.error(`[${userId}]에게 알림 발송 중 오류 발생:`, error); | ||
| throw error; // 오류를 다시 던져 Cloud Tasks가 재시도하도록 함 | ||
| logger.error("알림 발송 중 오류 발생", { | ||
| taskId, | ||
| error | ||
| }); | ||
| } finally { | ||
| try { | ||
| await taskDocRef.delete(); | ||
| } catch (cleanupError) { | ||
| logger.warn("notificationTask 정리 실패", { | ||
| taskId, | ||
| cleanupError | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| function isValidTaskId(value: unknown): value is string { | ||
| return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); | ||
| } | ||
|
|
||
| function hasPathSeparator(value: string): boolean { | ||
| return value.includes("/"); | ||
| } | ||
|
|
||
| function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): TaskPayload | null { | ||
| const { | ||
| userId, | ||
| todoId, | ||
| todoKind, | ||
| dueDateKey, | ||
| title, | ||
| body | ||
| } = data ?? {}; | ||
|
|
||
| if ( | ||
| typeof userId !== "string" || | ||
| typeof todoId !== "string" || | ||
| typeof todoKind !== "string" || | ||
| typeof dueDateKey !== "string" || | ||
| typeof title !== "string" || | ||
| typeof body !== "string" | ||
| ) { | ||
| return null; | ||
| } | ||
|
|
||
| if (hasPathSeparator(userId) || hasPathSeparator(todoId)) { | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| userId, | ||
| todoId, | ||
| todoKind, | ||
| dueDateKey, | ||
| title, | ||
| body | ||
| }; | ||
| } | ||
|
|
||
| function isAlreadyExistsError(error: unknown): boolean { | ||
| const code = (error as FirestoreErrorLike)?.code; | ||
| return code === 6 || code === "6" || code === "already-exists"; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
에러 발생 시
print문을 사용하는 것은 프로덕션 환경에서 적절한 로깅 방식이 아닙니다. 에러를 추적하고 디버깅하기 위해 전용 로깅 시스템(예: Crashlytics, Firebase Analytics의 커스텀 이벤트 등)을 사용하는 것이 좋습니다.