diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 5c9fb885..b89f5fac 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,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken]) } } + + private func updateUserTimeZone() { + Task { + do { + guard let uid = Auth.auth().currentUser?.uid else { return } + let settingsRef = Firestore.firestore().document("users/\(uid)/userData/settings") + + try await settingsRef.setData(["timeZone": TimeZone.autoupdatingCurrent.identifier], merge: true) + } catch { + 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..68f05dcf 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,11 @@ 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] @@ -57,13 +59,33 @@ 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], 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 + } + + 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)") } diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 68c23597..c5085821 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -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}`); + + const notificationData = { + title: "Todo 알림", + 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"; +} \ No newline at end of file diff --git a/Firebase/functions/src/fcm/schedule.ts b/Firebase/functions/src/fcm/schedule.ts index 825c59bc..a259d2bf 100644 --- a/Firebase/functions/src/fcm/schedule.ts +++ b/Firebase/functions/src/fcm/schedule.ts @@ -1,63 +1,296 @@ -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; +}; + +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; + } + + 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 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", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).formatToParts(date); + + const byType = (type: string): number => { + 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; + } +}