Skip to content

Commit 359dc3c

Browse files
authored
[#215] 푸시 알림에서 기록을 사용자가 제거하면 동일 todo에 대한 반복 알림이 오는 현상을 해결한다 (#217)
* feat: 중복용 데이터 컬렉션과 유저가 인터렉션하는 컬렉션 분리 * style: 변수명 수정 * feat: todo가 제거되면 중복 처리 컬렉션에서도 제거되도록 추가 * feat: todo가 제거되었을 때, 푸시알림 리스트에서도 제거되도록 추가 * feat: todo가 완료되고 dueDate도 이미 지났을 때 receipt에서 제거되도록 구현 * fix: 최신 Todo 상태를 기준으로 리마인더 알림을 발송하도록 수정 * fix: 마감일이 지난 완료 Todo의 receipt를 주기적으로 제거하도록 수정 * fix: 오래된 Todo receipt 정리 배치를 추가하고 remove 네이밍으로 정리 * refactor: 년월일 로직 단순화 및 추출 실패 시 경고 로그 출력 * refactor: 공통 로직 병합
1 parent 4b5a143 commit 359dc3c

5 files changed

Lines changed: 270 additions & 78 deletions

File tree

Firebase/functions/src/fcm/notification.ts

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { onTaskDispatched } from "firebase-functions/v2/tasks";
22
import * as admin from "firebase-admin";
33
import * as logger from "firebase-functions/logger";
4+
import { resolveTimeZone } from "./shared";
45

56
type TaskPayload = {
67
userId: string;
@@ -43,14 +44,48 @@ export const sendPushNotification = onTaskDispatched({
4344
}
4445
const { userId, todoId, todoKind, dueDateKey, title, body } = parsed;
4546

46-
const settingsDoc = await admin.firestore().doc(`users/${userId}/userData/settings`).get();
47-
const allowPushNotification = settingsDoc.data()?.allowPushNotification ?? true;
48-
if (!allowPushNotification) {
49-
return;
50-
}
47+
const settingsDocRef = admin.firestore().doc(`users/${userId}/userData/settings`);
48+
const todoDocRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
49+
const [settingsDoc, todoDoc] = await Promise.all([
50+
settingsDocRef.get(),
51+
todoDocRef.get()
52+
]);
53+
const settingsData = settingsDoc.data();
54+
const allowPushNotification = settingsData?.allowPushNotification ?? true;
55+
if (!allowPushNotification) { return; }
56+
57+
const todoData = todoDoc.data();
58+
if (!todoDoc.exists || !todoData || todoData.isCompleted === true) { return; }
59+
60+
const timeZone = resolveTimeZone(settingsData);
61+
62+
const dueDateValue = todoData.dueDate;
63+
const currentDueDate = dueDateValue instanceof admin.firestore.Timestamp ?
64+
dueDateValue.toDate() :
65+
dueDateValue instanceof Date ?
66+
dueDateValue :
67+
null;
68+
if (!currentDueDate) { return; }
69+
if (formatDateKey(currentDueDate, timeZone) !== dueDateKey) { return; }
70+
71+
const id = `${todoId}_${dueDateKey}`;
72+
const receiptDocRef = admin.firestore().doc(
73+
`users/${userId}/notificationReceipts/${id}`
74+
);
75+
const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${id}`);
5176

52-
const notificationDocId = `${todoId}_${dueDateKey}`;
53-
const notificationDocRef = admin.firestore().doc(`users/${userId}/notifications/${notificationDocId}`);
77+
try {
78+
await receiptDocRef.create({
79+
todoId,
80+
dueDateKey,
81+
createdAt: admin.firestore.FieldValue.serverTimestamp()
82+
});
83+
} catch (error) {
84+
if (isAlreadyExistsError(error)) {
85+
return;
86+
}
87+
throw error;
88+
}
5489

5590
const notificationData = {
5691
title: "Todo 알림",
@@ -60,14 +95,7 @@ export const sendPushNotification = onTaskDispatched({
6095
todoId: todoId,
6196
todoKind: todoKind
6297
};
63-
try {
64-
await notificationDocRef.create(notificationData);
65-
} catch (error) {
66-
if (isAlreadyExistsError(error)) {
67-
return;
68-
}
69-
throw error;
70-
}
98+
await notificationDocRef.set(notificationData, { merge: true });
7199

72100
// 1. 사용자 FCM 토큰 가져오기
73101
const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get();
@@ -117,10 +145,6 @@ function isValidTaskId(value: unknown): value is string {
117145
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
118146
}
119147

120-
function hasPathSeparator(value: string): boolean {
121-
return value.includes("/");
122-
}
123-
124148
function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): TaskPayload | null {
125149
const {
126150
userId,
@@ -142,7 +166,7 @@ function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): Tas
142166
return null;
143167
}
144168

145-
if (hasPathSeparator(userId) || hasPathSeparator(todoId)) {
169+
if (userId.includes("/") || todoId.includes("/")) {
146170
return null;
147171
}
148172

@@ -160,3 +184,27 @@ function isAlreadyExistsError(error: unknown): boolean {
160184
const code = (error as FirestoreErrorLike)?.code;
161185
return code === 6 || code === "6" || code === "already-exists";
162186
}
187+
188+
function formatDateKey(date: Date, timeZone: string): string {
189+
const parts = new Intl.DateTimeFormat("en-US", {
190+
timeZone,
191+
year: "numeric",
192+
month: "2-digit",
193+
day: "2-digit"
194+
}).formatToParts(date);
195+
196+
const partMap = new Map(parts.map(p => [p.type, p.value]));
197+
const year = partMap.get("year");
198+
const month = partMap.get("month");
199+
const day = partMap.get("day");
200+
201+
if (!year || !month || !day) {
202+
logger.warn("formatDateKey 파트 추출 실패", {
203+
date: date.toISOString(),
204+
timeZone,
205+
parts
206+
});
207+
}
208+
209+
return `${year ?? "1970"}-${month ?? "01"}-${day ?? "01"}`;
210+
}

Firebase/functions/src/fcm/schedule.ts

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { onSchedule } from "firebase-functions/v2/scheduler";
22
import { getFunctions } from "firebase-admin/functions";
33
import * as admin from "firebase-admin";
44
import * as logger from "firebase-functions/logger";
5+
import { resolveTimeZone } from "./shared";
56

67
const LOCATION = "asia-northeast3";
78
const DEFAULT_HOUR = 9;
89
const DEFAULT_MINUTE = 0;
9-
const DEFAULT_TIMEZONE = "UTC";
1010
const MINUTE_INTERVAL = 5;
1111

1212
type ZonedDateParts = {
@@ -58,20 +58,22 @@ export const scheduleTodoReminder = onSchedule({
5858
continue;
5959
}
6060
const settings = settingsDoc.data();
61-
if (!settings || settings.allowPushNotification !== true) {
62-
continue;
63-
}
64-
65-
const hour = Number.isInteger(settings.pushNotificationHour) ?
66-
settings.pushNotificationHour :
67-
DEFAULT_HOUR;
68-
const minute = normalizeMinute(settings.pushNotificationMinute);
61+
if (!settings || settings.allowPushNotification !== true) { continue; }
62+
63+
const hour = Number.isInteger(settings.pushNotificationHour) ? settings.pushNotificationHour : DEFAULT_HOUR;
64+
const configuredMinute = Number.isInteger(settings.pushNotificationMinute) ?
65+
Number(settings.pushNotificationMinute) :
66+
DEFAULT_MINUTE;
67+
const minute = configuredMinute < 0 || configuredMinute > 59 ?
68+
DEFAULT_MINUTE :
69+
configuredMinute - (configuredMinute % MINUTE_INTERVAL);
70+
6971
const timeZone = resolveTimeZone(settings);
7072

7173
const localNow = getZonedParts(now, timeZone);
72-
if (!isWithinNotificationWindow(localNow, hour, minute)) {
73-
continue;
74-
}
74+
if (localNow.hour !== hour) { continue; }
75+
const windowEnd = Math.min(minute + MINUTE_INTERVAL, 60);
76+
if (localNow.minute < minute || localNow.minute >= windowEnd) { continue; }
7577

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

93-
const dueDateKey = formatDateKey(startUTC, timeZone);
95+
const dueDateKey = `${tomorrow.year}-${tomorrow.month.toString().padStart(2, "0")}-${tomorrow.day.toString().padStart(2, "0")}`;
9496
let todosSnapshot: FirebaseFirestore.QuerySnapshot<FirebaseFirestore.DocumentData>;
9597
try {
9698
todosSnapshot = await admin.firestore()
9799
.collection(`users/${userId}/todoLists`)
98-
.where("isCompleted", "==", false)
99100
.where("dueDate", ">=", admin.firestore.Timestamp.fromDate(startUTC))
100101
.where("dueDate", "<", admin.firestore.Timestamp.fromDate(endUTC))
101102
.get();
102103
} catch (error) {
103104
logger.error("todoLists 조회 실패", {
104105
userId,
105-
at: "todoLists.where(isCompleted==false).where(dueDate>=start).where(dueDate<end)",
106+
at: "todoLists.where(dueDate>=start).where(dueDate<end)",
106107
startUTC: startUTC.toISOString(),
107108
endUTC: endUTC.toISOString(),
108109
dueDateKey,
@@ -197,20 +198,6 @@ function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
197198
};
198199
}
199200

200-
function formatDateKey(date: Date, timeZone: string): string {
201-
const parts = new Intl.DateTimeFormat("en-CA", {
202-
timeZone,
203-
year: "numeric",
204-
month: "2-digit",
205-
day: "2-digit"
206-
}).formatToParts(date);
207-
208-
const year = parts.find((part) => part.type === "year")?.value ?? "1970";
209-
const month = parts.find((part) => part.type === "month")?.value ?? "01";
210-
const day = parts.find((part) => part.type === "day")?.value ?? "01";
211-
return `${year}-${month}-${day}`;
212-
}
213-
214201
function parseShortOffsetToMinutes(shortOffset: string): number {
215202
if (shortOffset === "GMT" || shortOffset === "UTC") return 0;
216203
const match = shortOffset.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/);
@@ -265,32 +252,3 @@ function addDays(year: number, month: number, day: number, value: number): {
265252
day: utcDate.getUTCDate()
266253
};
267254
}
268-
269-
function normalizeMinute(value: unknown): number {
270-
if (!Number.isInteger(value)) return DEFAULT_MINUTE;
271-
const minute = Number(value);
272-
if (minute < 0 || minute > 59) return DEFAULT_MINUTE;
273-
return minute - (minute % MINUTE_INTERVAL);
274-
}
275-
276-
function isWithinNotificationWindow(
277-
localNow: ZonedDateParts,
278-
configuredHour: number,
279-
configuredMinute: number
280-
): boolean {
281-
if (localNow.hour !== configuredHour) return false;
282-
const windowStart = configuredMinute;
283-
const windowEnd = Math.min(configuredMinute + MINUTE_INTERVAL, 60);
284-
return localNow.minute >= windowStart && localNow.minute < windowEnd;
285-
}
286-
287-
function resolveTimeZone(settings: FirebaseFirestore.DocumentData | undefined): string {
288-
const candidate = settings?.timeZone ?? settings?.timezone ?? settings?.region;
289-
if (typeof candidate !== "string" || !candidate.trim()) return DEFAULT_TIMEZONE;
290-
try {
291-
new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date());
292-
return candidate;
293-
} catch {
294-
return DEFAULT_TIMEZONE;
295-
}
296-
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const DEFAULT_TIMEZONE = "UTC";
2+
3+
export function resolveTimeZone(settings: FirebaseFirestore.DocumentData | undefined): string {
4+
const candidate = settings?.timeZone ?? settings?.timezone ?? settings?.region;
5+
if (typeof candidate !== "string" || !candidate.trim()) { return DEFAULT_TIMEZONE; }
6+
7+
try {
8+
new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date());
9+
return candidate;
10+
} catch {
11+
return DEFAULT_TIMEZONE;
12+
}
13+
}

Firebase/functions/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ import {
3232
scheduleTodoReminder
3333
} from "./fcm/schedule";
3434

35+
import {
36+
removeTodoNotificationDocuments,
37+
removeCompletedTodoReceipts,
38+
removeStaleTodoReceipts
39+
} from "./todo/remove";
40+
3541

3642
// .env 파일 로드
3743
dotenv.config({
@@ -67,3 +73,9 @@ export {
6773
sendPushNotification,
6874
scheduleTodoReminder
6975
};
76+
77+
export {
78+
removeTodoNotificationDocuments,
79+
removeCompletedTodoReceipts,
80+
removeStaleTodoReceipts
81+
};

0 commit comments

Comments
 (0)