From b0c11043b4fc649050e8bcf18dfcca78c31f44c7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 25 Feb 2026 23:41:02 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=95=B1=EC=9D=B4=20=EC=98=A8?= =?UTF-8?q?=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C,=20=ED=98=B9=EC=9D=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=ED=95=A0=20=EB=95=8C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EC=9D=98=20=ED=98=84=EC=9E=AC=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=A1=B4=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppDelegate.swift | 18 +++++++++++++++++- DevLog/Infra/Service/UserService.swift | 4 +++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 5c9fb885..721327be 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -8,6 +8,8 @@ import Combine import UIKit import Firebase +import FirebaseAuth +import FirebaseFirestore import FirebaseMessaging import GoogleSignIn import UserNotifications @@ -39,7 +41,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { } } } - + + // 앱이 온그라운드로 되었을 때, 로그인 세션이 존재한다면 현재 유저의 timeZone 저장 + updateUserTimeZone() + // Firebase Messaging 설정 Messaging.messaging().delegate = self @@ -76,6 +81,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken]) } } + + private func updateUserTimeZone() { + guard let uid = Auth.auth().currentUser?.uid else { return } + let settingsRef = Firestore.firestore().document("users/\(uid)/userData/settings") + + settingsRef.setData(["timeZone": TimeZone.autoupdatingCurrent.identifier], merge: true) { error in + if let error { + print("Failed to update timeZone: \(error)") + } + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index 63250d16..0383688e 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -63,7 +63,9 @@ final class UserService { try await settingsRef.setData([ "allowPushNotification": true, "pushNotificationHour": 9, - "pushNotificationMinute": 0], merge: true) + "pushNotificationMinute": 0, + "timeZone": TimeZone.autoupdatingCurrent.identifier + ], merge: true) logger.info("Successfully upserted user: \(user.uid)") } From 3542b25e37d71370cf95e95160e04ddd2656bc13 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 11:46:10 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20try=20await=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppDelegate.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 721327be..b89f5fac 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -83,11 +83,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { } private func updateUserTimeZone() { - guard let uid = Auth.auth().currentUser?.uid else { return } - let settingsRef = Firestore.firestore().document("users/\(uid)/userData/settings") + Task { + do { + guard let uid = Auth.auth().currentUser?.uid else { return } + let settingsRef = Firestore.firestore().document("users/\(uid)/userData/settings") - settingsRef.setData(["timeZone": TimeZone.autoupdatingCurrent.identifier], merge: true) { error in - if let error { + try await settingsRef.setData(["timeZone": TimeZone.autoupdatingCurrent.identifier], merge: true) + } catch { print("Failed to update timeZone: \(error)") } } From 686ef379d3d91e706cd8d867162391a0c5dbe62f Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 12:07:57 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B3=91=EB=A0=AC=20=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EC=9A=94=EC=B2=AD=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/UserService.swift | 40 +++++++++++++++++--------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index 0383688e..48ea9b5e 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -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 ] - userField["currentProvider"] = response.providerID - // 공급자 이슈로 인한 nil 방지 if let email = user.email { userField["email"] = email @@ -48,8 +47,6 @@ final class UserService { user.displayName != nil && user.displayName != "" { userField["appleName"] = user.displayName } - - try await infoRef.setData(userField, merge: true) var settingField = ["fcmToken": response.fcmToken] @@ -57,15 +54,32 @@ final class UserService { 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, + // 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) + async let settingsTimeZoneUpdate: Void = settingsRef.setData([ "timeZone": TimeZone.autoupdatingCurrent.identifier ], merge: true) + + let settingsDocument = try await settingsRef.getDocument() + if !settingsDocument.exists { + try await settingsRef.setData([ + "allowPushNotification": true, + "pushNotificationHour": 9, + "pushNotificationMinute": 0 + ], merge: true) + } + + _ = try await (userUpdate, infoUpdate, tokensUpdate, settingsTimeZoneUpdate) logger.info("Successfully upserted user: \(user.uid)") } From e204c5f77ae61cde8d8cdb99899fe2b7c50cfa3f Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 12:08:18 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EC=9E=AC=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=95=A0=EB=95=8C=EB=A7=88=EB=8B=A4=20statusMsg=EA=B0=80=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/UserService.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index 48ea9b5e..5b3288d6 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -48,6 +48,11 @@ final class UserService { userField["appleName"] = user.displayName } + let userDocument = try await userRef.getDocument() + if !userDocument.exists { + userField["statusMsg"] = "" + } + var settingField = ["fcmToken": response.fcmToken] // 깃헙 로그인 시 추가 정보 저장 From c33c2a47cc5c976d867c7158e24fc00a3a67360d Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 16:19:36 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A0=84=EC=86=A1=20=EB=B0=8F=20Firestore=EC=97=90?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/fcm/notification.ts | 75 +++++++++++++++++----- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 68c23597..71a639a7 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -3,43 +3,86 @@ import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; // 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 { + userId, + todoId, + todoKind, + dueDateKey, + title, + body + } = req.data ?? {}; + + if ( + typeof userId !== "string" || + typeof todoId !== "string" || + typeof todoKind !== "string" || + typeof dueDateKey !== "string" || + typeof title !== "string" || + typeof body !== "string" + ) { + logger.warn("유효하지 않은 푸시 알림 payload", req.data); + return; + } try { + 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}`); + const alreadySentDoc = await notificationDocRef.get(); + if (alreadySentDoc.exists) { + return; + } + + const notificationData = { + title: "Todo 알림", + body, + receivedAt: admin.firestore.FieldValue.serverTimestamp(), + isRead: false, + todoID: todoId, + todoKind: todoKind + }; + await notificationDocRef.set(notificationData); + // 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가 재시도하도록 함 } } ); From 653ebddd3b9c639a067fbcd8c3096e66c39c61eb Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 16:21:32 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=9E=8C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=94=B0=EB=9D=BC=20Firestore=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EA=B3=A0,=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=9E=8C=EC=9D=84=20=EB=B3=B4=EB=82=B4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/fcm/schedule.ts | 310 +++++++++++++++++++++---- 1 file changed, 264 insertions(+), 46 deletions(-) diff --git a/Firebase/functions/src/fcm/schedule.ts b/Firebase/functions/src/fcm/schedule.ts index 825c59bc..83173030 100644 --- a/Firebase/functions/src/fcm/schedule.ts +++ b/Firebase/functions/src/fcm/schedule.ts @@ -1,63 +1,281 @@ -import { onDocumentWritten } from "firebase-functions/v2/firestore"; +import { onSchedule } from "firebase-functions/v2/scheduler"; import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; -const LOCATION = "asia-northeast3"; +const LOCATION = "asia-northeast3"; +const DEFAULT_HOUR = 9; +const DEFAULT_MINUTE = 0; +const DEFAULT_TIMEZONE = "UTC"; +const MINUTE_INTERVAL = 5; -// 할 일(Todo) 문서가 생성되거나 업데이트될 때마다 실행 -export const scheduleTodoReminder = onDocumentWritten({ +type ZonedDateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; +}; + +type ErrorLike = { + code?: unknown; + details?: unknown; + message?: unknown; + stack?: unknown; +}; + +function serializeError(error: unknown): Record { + const err = error as ErrorLike; + return { + code: err?.code ?? null, + details: err?.details ?? null, + message: err?.message ?? String(error), + stack: err?.stack ?? null + }; +} + +export const scheduleTodoReminder = onSchedule({ region: LOCATION, - document: "users/{userId}/todoLists/{todoId}", + schedule: "*/5 * * * *", + timeZone: "UTC" }, async (event) => { - const todoData = event.data?.after.data(); - const userId = event.params.userId; + try { + const now = event.scheduleTime ? new Date(event.scheduleTime) : new Date(); + const queue = getFunctions().taskQueue(`locations/${LOCATION}/functions/sendPushNotification`); + let usersSnapshot: FirebaseFirestore.QuerySnapshot; + try { + usersSnapshot = await admin.firestore().collection("users").get(); + } catch (error) { + logger.error("users 조회 실패", { + at: "collection(users).get()", + ...serializeError(error) + }); + return; + } - // 할 일이 삭제되었거나 dueDate가 없으면 작업 중지 - if (!todoData || !todoData.dueDate) { - logger.info(`Todo가 삭제되었거나 dueDate가 없어 스케줄링하지 않습니다.`); - return; - } + for (const userDoc of usersSnapshot.docs) { + const userId = userDoc.id; + let settingsDoc: FirebaseFirestore.DocumentSnapshot; + try { + settingsDoc = await admin.firestore().doc(`users/${userId}/userData/settings`).get(); + } catch (error) { + logger.error("settings 조회 실패", { + userId, + at: "users/{uid}/userData/settings", + ...serializeError(error) + }); + continue; + } + const settings = settingsDoc.data(); + if (!settings || settings.allowPushNotification !== true) { + continue; + } + + const hour = Number.isInteger(settings.pushNotificationHour) ? + settings.pushNotificationHour : + DEFAULT_HOUR; + const minute = normalizeMinute(settings.pushNotificationMinute); + const timeZone = resolveTimeZone(settings); - try { - // 1. 사용자의 알림 설정 시간 가져오기 (기본값: 오전 9시) - const settingsDoc = await admin.firestore().doc(`users/${userId}/userData/settings`).get(); - const settings = settingsDoc.data(); - const pushNotificationHour = settings?.pushNotificationHour ?? 9; // 설정 없으면 오전 9시 - - // 2. 실제 알림 보낼 시간 계산 (마감일 하루 전, 사용자가 설정한 시각) - const dueDate = todoData.dueDate.toDate(); // Firestore Timestamp를 JS Date로 변환 - const notificationDate = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate() - 1, pushNotificationHour, 0, 0); - - // 3. Cloud Tasks 큐에 작업 예약 - const queue = getFunctions().taskQueue( - `locations/${LOCATION}/functions/sendPushNotification` - ); - // KST(UTC+9) → UTC 변환 - const notificationDateKST = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate() - 1, pushNotificationHour, 0, 0); - // notificationDateKST는 KST 기준 - const notificationDateUTC = new Date(notificationDateKST.getTime() - (9 * 60 * 60 * 1000)); // 9시간 빼기 - - await queue.enqueue( - { - userId: userId, - todoId: event.params.todoId, - title: "DevLog", - body: `'${todoData.title || '제목 없음'}'의 마감일이 내일입니다.`, - kind: todoData.kind || "etc", - receivedDate: admin.firestore.Timestamp.fromDate(notificationDateKST), // Date → Firestore Timestamp - isRead: false - }, - { - scheduleTime: notificationDateUTC, + const localNow = getZonedParts(now, timeZone); + if (!isWithinNotificationWindow(localNow, hour, minute)) { + continue; } - ); - logger.info(`[${userId}]의 Todo '${todoData.title}'에 대한 알림을 ${notificationDate.toLocaleString()}에 예약했습니다.`); + const tomorrow = addDays(localNow.year, localNow.month, localNow.day, 1); + const dayAfterTomorrow = addDays(localNow.year, localNow.month, localNow.day, 2); + const startUTC = zonedDateTimeToUTC( + tomorrow.year, + tomorrow.month, + tomorrow.day, + 0, 0, + timeZone + ); + const endUTC = zonedDateTimeToUTC( + dayAfterTomorrow.year, + dayAfterTomorrow.month, + dayAfterTomorrow.day, + 0, 0, + timeZone + ); + + const dueDateKey = formatDateKey(startUTC, timeZone); + let todosSnapshot: FirebaseFirestore.QuerySnapshot; + try { + todosSnapshot = await admin.firestore() + .collection(`users/${userId}/todoLists`) + .where("isCompleted", "==", false) + .where("dueDate", ">=", admin.firestore.Timestamp.fromDate(startUTC)) + .where("dueDate", "<", admin.firestore.Timestamp.fromDate(endUTC)) + .get(); + } catch (error) { + logger.error("todoLists 조회 실패", { + userId, + at: "todoLists.where(isCompleted==false).where(dueDate>=start).where(dueDate { + const found = parts.find((part) => part.type === type)?.value; + return Number(found); + }; + + return { + year: byType("year"), + month: byType("month"), + day: byType("day"), + hour: byType("hour"), + minute: byType("minute") + }; +} + +function formatDateKey(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(date); + + const year = parts.find((part) => part.type === "year")?.value ?? "1970"; + const month = parts.find((part) => part.type === "month")?.value ?? "01"; + const day = parts.find((part) => part.type === "day")?.value ?? "01"; + return `${year}-${month}-${day}`; +} + +function parseShortOffsetToMinutes(shortOffset: string): number { + if (shortOffset === "GMT" || shortOffset === "UTC") return 0; + const match = shortOffset.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/); + if (!match) return 0; + + const sign = match[1] === "-" ? -1 : 1; + const hour = Number(match[2]); + const minute = Number(match[3] ?? "0"); + return sign * (hour * 60 + minute); +} + +function getOffsetMinutesAt(utcDate: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "shortOffset" + }).formatToParts(utcDate); + + const offset = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT"; + return parseShortOffsetToMinutes(offset); +} + +function zonedDateTimeToUTC( + year: number, + month: number, + day: number, + hour: number, + minute: number, + timeZone: string +): Date { + const localAsUTC = Date.UTC(year, month - 1, day, hour, minute, 0, 0); + let utcMs = localAsUTC; + + // DST 경계 시 오프셋이 바뀔 수 있어 2회 보정 + for (let i = 0; i < 2; i += 1) { + const offsetMinutes = getOffsetMinutesAt(new Date(utcMs), timeZone); + utcMs = localAsUTC - offsetMinutes * 60 * 1000; + } + + return new Date(utcMs); +} + +function addDays(year: number, month: number, day: number, value: number): { + year: number; + month: number; + day: number; +} { + const utcDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); + utcDate.setUTCDate(utcDate.getUTCDate() + value); + return { + year: utcDate.getUTCFullYear(), + month: utcDate.getUTCMonth() + 1, + day: utcDate.getUTCDate() + }; +} + +function normalizeMinute(value: unknown): number { + if (!Number.isInteger(value)) return DEFAULT_MINUTE; + const minute = Number(value); + if (minute < 0 || minute > 59) return DEFAULT_MINUTE; + return minute - (minute % MINUTE_INTERVAL); +} + +function isWithinNotificationWindow( + localNow: ZonedDateParts, + configuredHour: number, + configuredMinute: number +): boolean { + if (localNow.hour !== configuredHour) return false; + const windowStart = configuredMinute; + const windowEnd = Math.min(configuredMinute + MINUTE_INTERVAL, 60); + return localNow.minute >= windowStart && localNow.minute < windowEnd; +} + +function resolveTimeZone(settings: FirebaseFirestore.DocumentData | undefined): string { + const candidate = settings?.timeZone ?? settings?.timezone ?? settings?.region; + if (typeof candidate !== "string" || !candidate.trim()) return DEFAULT_TIMEZONE; + try { + new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date()); + return candidate; + } catch { + return DEFAULT_TIMEZONE; + } +} From 9e3c94ef0bf194b017f445ee75d77063668391ac Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 17:12:17 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20setData=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=84=20=ED=95=9C=EB=B2=88=EB=A7=8C=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/UserService.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index 5b3288d6..68f05dcf 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -71,20 +71,21 @@ final class UserService { ) async let infoUpdate: Void = infoRef.setData(userFieldSnapshot, merge: true) async let tokensUpdate: Void = tokensRef.setData(settingFieldSnapshot, merge: true) - async let settingsTimeZoneUpdate: Void = settingsRef.setData([ - "timeZone": TimeZone.autoupdatingCurrent.identifier - ], merge: true) let settingsDocument = try await settingsRef.getDocument() + var settingsField: [String: Any] = [ + "timeZone": TimeZone.autoupdatingCurrent.identifier + ] if !settingsDocument.exists { - try await settingsRef.setData([ - "allowPushNotification": true, - "pushNotificationHour": 9, - "pushNotificationMinute": 0 - ], merge: true) + settingsField["allowPushNotification"] = true + settingsField["pushNotificationHour"] = 9 + settingsField["pushNotificationMinute"] = 0 } - _ = try await (userUpdate, infoUpdate, tokensUpdate, settingsTimeZoneUpdate) + 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)") } From 720726eb223d3334a01cbb52ee8cc2dccec0c67a Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 26 Feb 2026 17:38:45 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20Firebase=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=ED=81=90=EC=97=90=20enqueue=20=EC=8B=9C=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EC=9D=98=20=EC=9E=91=EC=97=85ID=EB=A5=BC=20=EB=B6=80?= =?UTF-8?q?=EC=97=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/fcm/notification.ts | 121 +++++++++++++++++---- Firebase/functions/src/fcm/schedule.ts | 51 ++++++--- 2 files changed, 131 insertions(+), 41 deletions(-) diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 71a639a7..c5085821 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -2,6 +2,19 @@ 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({ region: "asia-northeast3", @@ -9,28 +22,27 @@ export const sendPushNotification = onTaskDispatched({ rateLimits: { maxDispatchesPerSecond: 200 }, }, async (req) => { - const { - userId, - todoId, - todoKind, - dueDateKey, - title, - body - } = req.data ?? {}; - - if ( - typeof userId !== "string" || - typeof todoId !== "string" || - typeof todoKind !== "string" || - typeof dueDateKey !== "string" || - typeof title !== "string" || - typeof body !== "string" - ) { + 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) { @@ -39,10 +51,6 @@ export const sendPushNotification = onTaskDispatched({ const notificationDocId = `${todoId}_${dueDateKey}`; const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${notificationDocId}`); - const alreadySentDoc = await notificationDocRef.get(); - if (alreadySentDoc.exists) { - return; - } const notificationData = { title: "Todo 알림", @@ -52,7 +60,14 @@ export const sendPushNotification = onTaskDispatched({ todoID: todoId, todoKind: todoKind }; - await notificationDocRef.set(notificationData); + 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(); @@ -82,7 +97,67 @@ export const sendPushNotification = onTaskDispatched({ } } catch (error) { - logger.error(`[${userId}]에게 알림 발송 중 오류 발생:`, error); + 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"; +} \ No newline at end of file diff --git a/Firebase/functions/src/fcm/schedule.ts b/Firebase/functions/src/fcm/schedule.ts index 83173030..a259d2bf 100644 --- a/Firebase/functions/src/fcm/schedule.ts +++ b/Firebase/functions/src/fcm/schedule.ts @@ -24,16 +24,6 @@ type ErrorLike = { stack?: unknown; }; -function serializeError(error: unknown): Record { - const err = error as ErrorLike; - return { - code: err?.code ?? null, - details: err?.details ?? null, - message: err?.message ?? String(error), - stack: err?.stack ?? null - }; -} - export const scheduleTodoReminder = onSchedule({ region: LOCATION, schedule: "*/5 * * * *", @@ -130,20 +120,36 @@ export const scheduleTodoReminder = onSchedule({ todoData.kind : "etc"; + const notificationTaskRef = admin.firestore().collection("notificationTasks").doc(); + const notificationTaskData = { + userId, + todoId: todoDoc.id, + todoKind, + dueDateKey, + title: "DevLog", + body: `'${todoTitle}'의 마감일이 내일입니다.`, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }; + try { - await queue.enqueue({ - userId, - todoId: todoDoc.id, - todoKind, - dueDateKey, - title: "DevLog", - body: `'${todoTitle}'의 마감일이 내일입니다.` - }); + await notificationTaskRef.set(notificationTaskData); + await queue.enqueue({ taskId: notificationTaskRef.id }); } catch (error) { + try { + await notificationTaskRef.delete(); + } catch (cleanupError) { + logger.warn("notificationTasks 정리 실패", { + userId, + todoId: todoDoc.id, + taskId: notificationTaskRef.id, + ...serializeError(cleanupError) + }); + } logger.error("Cloud Tasks enqueue 실패", { userId, todoId: todoDoc.id, dueDateKey, + taskId: notificationTaskRef.id, ...serializeError(error) }); } @@ -156,6 +162,15 @@ export const scheduleTodoReminder = onSchedule({ } ); +function serializeError(error: unknown): Record { + const err = error as ErrorLike; + return { + code: err?.code ?? null, + details: err?.details ?? null, + message: err?.message ?? String(error), + stack: err?.stack ?? null + }; +} function getZonedParts(date: Date, timeZone: string): ZonedDateParts { const parts = new Intl.DateTimeFormat("en-US", {