Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
86 changes: 67 additions & 19 deletions Firebase/functions/src/fcm/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,57 @@ 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 candidate = settingsData?.timeZone ?? settingsData?.timezone ?? settingsData?.region;
let timeZone = "UTC";
if (typeof candidate === "string" && candidate.trim()) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date());
timeZone = candidate;
} catch {
timeZone = "UTC";
}
}

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.

medium

사용자의 타임존을 확인하는 로직이 Firebase/functions/src/fcm/schedule.ts 파일 (71-80행)에도 중복으로 존재합니다. 이러한 코드 중복은 향후 유지보수를 어렵게 만들 수 있습니다. 한 곳을 수정할 때 다른 곳도 함께 수정해야 하는 번거로움이 생깁니다.

이 로직을 별도의 유틸리티 함수(예: resolveTimeZone(settings))로 추출하여 공통으로 사용하는 것이 좋습니다. 이렇게 하면 코드가 더 깔끔해지고 유지보수성이 향상됩니다.

이번 PR에서 schedule.ts에 있던 resolveTimeZone 헬퍼 함수를 제거하고 내용을 인라인으로 바꾸셨는데, 동일한 코드가 notification.ts에도 추가되면서 코드 중복이 발생했습니다. 헬퍼 함수를 다시 도입하여 두 파일에서 모두 사용하도록 리팩토링하는 것을 권장합니다.


const notificationDocId = `${todoId}_${dueDateKey}`;
const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${notificationDocId}`);
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}`);

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 +103,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 +153,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 +174,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 +192,19 @@ 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 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")}`;
}
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}`;
}

81 changes: 24 additions & 57 deletions Firebase/functions/src/fcm/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,31 @@ export const scheduleTodoReminder = onSchedule({
continue;
}
const settings = settingsDoc.data();
if (!settings || settings.allowPushNotification !== true) {
continue;
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 candidate = settings.timeZone ?? settings.timezone ?? settings.region;
let timeZone = DEFAULT_TIMEZONE;
if (typeof candidate === "string" && candidate.trim()) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date());
timeZone = candidate;
} catch {
timeZone = DEFAULT_TIMEZONE;
}
}

const hour = Number.isInteger(settings.pushNotificationHour) ?
settings.pushNotificationHour :
DEFAULT_HOUR;
const minute = normalizeMinute(settings.pushNotificationMinute);
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 +101,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 +207,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 +261,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;
}
}
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