Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 68 additions & 20 deletions Firebase/functions/src/fcm/notification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { onTaskDispatched } from "firebase-functions/v2/tasks";
import * as admin from "firebase-admin";
import * as logger from "firebase-functions/logger";
import { resolveTimeZone } from "./shared";

type TaskPayload = {
userId: string;
Expand Down Expand Up @@ -43,14 +44,48 @@ export const sendPushNotification = onTaskDispatched({
}
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 settingsDocRef = admin.firestore().doc(`users/${userId}/userData/settings`);
const todoDocRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
const [settingsDoc, todoDoc] = await Promise.all([
settingsDocRef.get(),
todoDocRef.get()
]);
const settingsData = settingsDoc.data();
const allowPushNotification = settingsData?.allowPushNotification ?? true;
if (!allowPushNotification) { return; }

const todoData = todoDoc.data();
if (!todoDoc.exists || !todoData || todoData.isCompleted === true) { return; }

const timeZone = resolveTimeZone(settingsData);

const dueDateValue = todoData.dueDate;
const currentDueDate = dueDateValue instanceof admin.firestore.Timestamp ?
dueDateValue.toDate() :
dueDateValue instanceof Date ?
dueDateValue :
null;
if (!currentDueDate) { return; }
if (formatDateKey(currentDueDate, timeZone) !== dueDateKey) { return; }

const id = `${todoId}_${dueDateKey}`;
const receiptDocRef = admin.firestore().doc(
`users/${userId}/notificationReceipts/${id}`
);
const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${id}`);

const notificationDocId = `${todoId}_${dueDateKey}`;
const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${notificationDocId}`);
try {
await receiptDocRef.create({
todoId,
dueDateKey,
createdAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
if (isAlreadyExistsError(error)) {
return;
}
throw error;
}

const notificationData = {
title: "Todo 알림",
Expand All @@ -60,14 +95,7 @@ export const sendPushNotification = onTaskDispatched({
todoId: todoId,
todoKind: todoKind
};
try {
await notificationDocRef.create(notificationData);
} catch (error) {
if (isAlreadyExistsError(error)) {
return;
}
throw error;
}
await notificationDocRef.set(notificationData, { merge: true });

// 1. 사용자 FCM 토큰 가져오기
const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get();
Expand Down Expand Up @@ -117,10 +145,6 @@ 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,
Expand All @@ -142,7 +166,7 @@ function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): Tas
return null;
}

if (hasPathSeparator(userId) || hasPathSeparator(todoId)) {
if (userId.includes("/") || todoId.includes("/")) {
return null;
}

Expand All @@ -160,3 +184,27 @@ function isAlreadyExistsError(error: unknown): boolean {
const code = (error as FirestoreErrorLike)?.code;
return code === 6 || code === "6" || code === "already-exists";
}

function formatDateKey(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);

const partMap = new Map(parts.map(p => [p.type, p.value]));
const year = partMap.get("year");
const month = partMap.get("month");
const day = partMap.get("day");

if (!year || !month || !day) {
logger.warn("formatDateKey 파트 추출 실패", {
date: date.toISOString(),
timeZone,
parts
});
}

return `${year ?? "1970"}-${month ?? "01"}-${day ?? "01"}`;
}
Comment on lines +188 to +210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

formatDateKey 함수에 잠재적인 버그와 비효율적인 부분이 있습니다.

  1. 버그: parts.find(...)undefined를 반환하면 Number(undefined)NaN이 됩니다. 이로 인해 문서 ID가 NaN-NaN-NaN과 같은 형태로 생성될 수 있습니다.
  2. 비효율: { month: "2-digit", day: "2-digit" } 옵션으로 인해 월과 일은 이미 0으로 채워진 2자리 문자열(예: "05")입니다. 이를 숫자로 변환했다가 다시 문자열로 변환하는 과정은 불필요합니다.

formatToParts가 반환하는 값을 직접 사용하고, undefined를 안전하게 처리하도록 함수를 개선하는 것이 좋습니다. 아래와 같이 수정하는 것을 제안합니다.

Suggested change
function formatDateKey(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const byType = (type: string): number => {
const found = parts.find((part) => part.type === type)?.value;
return Number(found);
};
return `${byType("year")}-${byType("month").toString().padStart(2, "0")}-${byType("day").toString().padStart(2, "0")}`;
}
function formatDateKey(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const partMap = new Map(parts.map(p => [p.type, p.value]));
const year = partMap.get("year") ?? "1970";
const month = partMap.get("month") ?? "01";
const day = partMap.get("day") ?? "01";
return `${year}-${month}-${day}`;
}

74 changes: 16 additions & 58 deletions Firebase/functions/src/fcm/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ 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";
import { resolveTimeZone } from "./shared";

const LOCATION = "asia-northeast3";
const DEFAULT_HOUR = 9;
const DEFAULT_MINUTE = 0;
const DEFAULT_TIMEZONE = "UTC";
const MINUTE_INTERVAL = 5;

type ZonedDateParts = {
Expand Down Expand Up @@ -58,20 +58,22 @@ export const scheduleTodoReminder = onSchedule({
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);
if (!settings || settings.allowPushNotification !== true) { continue; }

const hour = Number.isInteger(settings.pushNotificationHour) ? settings.pushNotificationHour : DEFAULT_HOUR;
const configuredMinute = Number.isInteger(settings.pushNotificationMinute) ?
Number(settings.pushNotificationMinute) :
DEFAULT_MINUTE;
const minute = configuredMinute < 0 || configuredMinute > 59 ?
DEFAULT_MINUTE :
configuredMinute - (configuredMinute % MINUTE_INTERVAL);

const timeZone = resolveTimeZone(settings);

const localNow = getZonedParts(now, timeZone);
if (!isWithinNotificationWindow(localNow, hour, minute)) {
continue;
}
if (localNow.hour !== hour) { continue; }
const windowEnd = Math.min(minute + MINUTE_INTERVAL, 60);
if (localNow.minute < minute || localNow.minute >= windowEnd) { continue; }

const tomorrow = addDays(localNow.year, localNow.month, localNow.day, 1);
const dayAfterTomorrow = addDays(localNow.year, localNow.month, localNow.day, 2);
Expand All @@ -90,19 +92,18 @@ export const scheduleTodoReminder = onSchedule({
timeZone
);

const dueDateKey = formatDateKey(startUTC, timeZone);
const dueDateKey = `${tomorrow.year}-${tomorrow.month.toString().padStart(2, "0")}-${tomorrow.day.toString().padStart(2, "0")}`;
let todosSnapshot: FirebaseFirestore.QuerySnapshot<FirebaseFirestore.DocumentData>;
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<end)",
at: "todoLists.where(dueDate>=start).where(dueDate<end)",
startUTC: startUTC.toISOString(),
endUTC: endUTC.toISOString(),
dueDateKey,
Expand Down Expand Up @@ -197,20 +198,6 @@ function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
};
}

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}))?$/);
Expand Down Expand Up @@ -265,32 +252,3 @@ function addDays(year: number, month: number, day: number, value: number): {
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;
}
}
13 changes: 13 additions & 0 deletions Firebase/functions/src/fcm/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const DEFAULT_TIMEZONE = "UTC";

export 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;
}
}
12 changes: 12 additions & 0 deletions Firebase/functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ import {
scheduleTodoReminder
} from "./fcm/schedule";

import {
removeTodoNotificationDocuments,
removeCompletedTodoReceipts,
removeStaleTodoReceipts
} from "./todo/remove";


// .env 파일 로드
dotenv.config({
Expand Down Expand Up @@ -67,3 +73,9 @@ export {
sendPushNotification,
scheduleTodoReminder
};

export {
removeTodoNotificationDocuments,
removeCompletedTodoReceipts,
removeStaleTodoReceipts
};
Loading