diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 2d8e16e8..7d2a9641 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -12,6 +12,7 @@ struct TodoRequest: Encodable { let isPinned: Bool let isCompleted: Bool let isChecked: Bool + let isDeleted: Bool let number: Int? let title: String let content: String diff --git a/DevLog/Data/DTO/WebPageDTO.swift b/DevLog/Data/DTO/WebPageDTO.swift index 980ec255..6e5cadce 100644 --- a/DevLog/Data/DTO/WebPageDTO.swift +++ b/DevLog/Data/DTO/WebPageDTO.swift @@ -12,6 +12,7 @@ struct WebPageRequest: Encodable { let url: String let displayURL: String let imageURL: String + let isDeleted: Bool } struct WebPageResponse { diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index e879a1e7..15b55d52 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -12,6 +12,7 @@ extension TodoRequest { isPinned: entity.isPinned, isCompleted: entity.isCompleted, isChecked: entity.isChecked, + isDeleted: false, number: entity.number, title: entity.title, content: entity.content, diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index 21bded87..8b341707 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -48,7 +48,8 @@ final class WebPageRepositoryImpl: WebPageRepository { title: metadata.title, url: urlString, displayURL: metadata.displayURL, - imageURL: metadata.imageURL + imageURL: metadata.imageURL, + isDeleted: false ) try await webPageService.upsertWebPage(request) } @@ -101,7 +102,8 @@ private extension WebPageRepositoryImpl { title: metadata.title, url: response.url, displayURL: metadata.displayURL, - imageURL: metadata.imageURL + imageURL: metadata.imageURL, + isDeleted: false ) try await webPageService.upsertWebPage(request) diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index c3c74d68..a13ab372 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -128,7 +128,8 @@ final class PushNotificationService { let items = snapshot.documents.compactMap { makeResponse(from: $0) } let nextCursor: PushNotificationCursorDTO? = snapshot.documents.last.map { document in - guard let receivedAt = document.data()[Key.receivedAt.rawValue] as? Timestamp else { + guard let receivedAt = document.data()[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp + else { return nil } @@ -184,6 +185,7 @@ final class PushNotificationService { let subject = PassthroughSubject() let listener = store.collection(FirestorePath.notifications(uid)) .whereField("isRead", isEqualTo: false) + .whereField(PushNotificationFieldKey.isDeleted.rawValue, isEqualTo: false) .addSnapshotListener { snapshot, error in if let error { subject.send(completion: .failure(error)) @@ -192,7 +194,7 @@ final class PushNotificationService { guard let snapshot else { return } let unreadPushCount = snapshot.documents.filter { document in - !(document.data()[Key.deletingAt.rawValue] is Timestamp) + !(document.data()[PushNotificationFieldKey.deletingAt.rawValue] is Timestamp) }.count subject.send(unreadPushCount) } @@ -238,7 +240,10 @@ final class PushNotificationService { } let collection = store.collection(FirestorePath.notifications(uid)) - let snapshot = try await collection.whereField("todoId", isEqualTo: todoId).getDocuments() + let snapshot = try await collection + .whereField("todoId", isEqualTo: todoId) + .whereField(PushNotificationFieldKey.isDeleted.rawValue, isEqualTo: false) + .getDocuments() guard let document = snapshot.documents.first else { logger.error("Notification not found for todoId: \(todoId)") @@ -265,6 +270,7 @@ private extension PushNotificationService { query: PushNotificationQuery ) -> Query { var firestoreQuery: Query = store.collection(FirestorePath.notifications(uid)) + .whereField(PushNotificationFieldKey.isDeleted.rawValue, isEqualTo: false) if let thresholdDate = query.timeFilter.thresholdDate { firestoreQuery = firestoreQuery.whereField( @@ -286,7 +292,7 @@ private extension PushNotificationService { func makeNextCursor(from document: QueryDocumentSnapshot?) -> PushNotificationCursorDTO? { guard let document, - let receivedAt = document.data()[Key.receivedAt.rawValue] as? Timestamp else { + let receivedAt = document.data()[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp else { return nil } @@ -298,16 +304,17 @@ private extension PushNotificationService { func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? { let data = snapshot.data() - if data[Key.deletingAt.rawValue] is Timestamp { + if data[PushNotificationFieldKey.deletingAt.rawValue] is Timestamp || + (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { return nil } guard - let title = data[Key.title.rawValue] as? String, - let body = data[Key.body.rawValue] as? String, - let receivedAt = data[Key.receivedAt.rawValue] as? Timestamp, - let isRead = data[Key.isRead.rawValue] as? Bool, - let todoId = data[Key.todoId.rawValue] as? String, - let todoCategory = data[Key.todoCategory.rawValue] as? String else { + let title = data[PushNotificationFieldKey.title.rawValue] as? String, + let body = data[PushNotificationFieldKey.body.rawValue] as? String, + let receivedAt = data[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp, + let isRead = data[PushNotificationFieldKey.isRead.rawValue] as? Bool, + let todoId = data[PushNotificationFieldKey.todoId.rawValue] as? String, + let todoCategory = data[PushNotificationFieldKey.todoCategory.rawValue] as? String else { return nil } @@ -322,13 +329,14 @@ private extension PushNotificationService { ) } - enum Key: String { + enum PushNotificationFieldKey: String { case title case body case receivedAt case isRead case todoId case todoCategory - case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 + case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 + case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } } diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 4ad84cb0..20736dda 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -227,9 +227,12 @@ final class TodoService { logger.info("Fetching todo") do { - let docRef = store.document(FirestorePath.todo(uid, todoId: todoId)) - let snapshot = try await docRef.getDocument() - guard snapshot.exists, let todo = makeResponse(from: snapshot) else { + let snapshot = try await store.collection(FirestorePath.todos(uid)) + .whereField(FieldPath.documentID(), isEqualTo: todoId) + .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) + .limit(to: 1) + .getDocuments() + guard let document = snapshot.documents.first, let todo = makeResponse(from: document) else { throw FirestoreError.dataNotFound("Todo") } @@ -253,6 +256,7 @@ final class TodoService { group.addTask { let snapshot = try await collection .whereField(TodoFieldKey.number.rawValue, in: chunk) + .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) .getDocuments() return snapshot.documents } @@ -269,6 +273,7 @@ final class TodoService { let data = document.data() guard !(data[TodoFieldKey.deletingAt.rawValue] is Timestamp), + (data[TodoFieldKey.isDeleted.rawValue] as? Bool) != true, let response = makeResponse(from: document) else { return @@ -355,6 +360,7 @@ private extension TodoService { func makeQuery(uid: String, query: TodoQuery) -> Query { let collection = store.collection(FirestorePath.todos(uid)) + .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) switch query.sortTarget { case .dueDate: @@ -426,7 +432,8 @@ private extension TodoService { } func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { - if snapshot.data()[TodoFieldKey.deletingAt.rawValue] is Timestamp { + if snapshot.data()[TodoFieldKey.deletingAt.rawValue] is Timestamp || + (snapshot.data()[TodoFieldKey.isDeleted.rawValue] as? Bool) == true { return nil } return makeResponse(documentID: snapshot.documentID, data: snapshot.data()) @@ -440,6 +447,10 @@ private extension TodoService { } func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { + if data[TodoFieldKey.deletingAt.rawValue] is Timestamp || + (data[TodoFieldKey.isDeleted.rawValue] as? Bool) == true { + return nil + } guard let isPinned = data[TodoFieldKey.isPinned.rawValue] as? Bool, let isCompleted = data[TodoFieldKey.isCompleted.rawValue] as? Bool, @@ -487,7 +498,8 @@ private extension TodoService { case dueDate case tags case category - case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 + case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 + case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } enum CounterFieldKey: String { diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 49c75a9a..709e925a 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -31,6 +31,7 @@ final class WebPageService { do { let collectionRef = store.collection(FirestorePath.webPages(uid)) + .whereField(WebPageFieldKey.isDeleted.rawValue, isEqualTo: false) let snapshot = try await collectionRef.getDocuments() let items: [WebPageResponse] = snapshot.documents.compactMap { makeResponse(from: $0) } @@ -128,6 +129,7 @@ private extension WebPageService { return nil } guard + (data[WebPageFieldKey.isDeleted.rawValue] as? Bool) != true, let title = data[WebPageFieldKey.title.rawValue] as? String, let url = data[WebPageFieldKey.url.rawValue] as? String, let displayURL = data[WebPageFieldKey.displayURL.rawValue] as? String, @@ -149,6 +151,7 @@ private extension WebPageService { case url case displayURL case imageURL - case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 + case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 + case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } } diff --git a/Firebase/firebase.json b/Firebase/firebase.json index a6cd0d5e..938146f8 100644 --- a/Firebase/firebase.json +++ b/Firebase/firebase.json @@ -15,6 +15,9 @@ ] } ], + "firestore": { + "indexes": "firestore.index.json" + }, "emulators": { "functions": { "port": 5001 diff --git a/Firebase/firestore.index.json b/Firebase/firestore.index.json new file mode 100644 index 00000000..bc9e032e --- /dev/null +++ b/Firebase/firestore.index.json @@ -0,0 +1,577 @@ +{ + "indexes": [ + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "dueDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "category", + "order": "ASCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isPinned", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "dueDate", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "dueDate", + "order": "DESCENDING" + }, + { + "fieldPath": "isCompleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "receivedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "receivedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isRead", + "order": "ASCENDING" + }, + { + "fieldPath": "receivedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isDeleted", + "order": "ASCENDING" + }, + { + "fieldPath": "isRead", + "order": "ASCENDING" + }, + { + "fieldPath": "receivedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/Firebase/functions/src/common/date.ts b/Firebase/functions/src/common/date.ts new file mode 100644 index 00000000..ed2d88de --- /dev/null +++ b/Firebase/functions/src/common/date.ts @@ -0,0 +1,136 @@ +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; + +type ZonedDateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; +}; + +// 지정한 타임존 기준 연월일시분 값 추출 +export 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") + }; +} + +// GMT 오프셋 문자열의 분 단위 오프셋 값 변환 +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); +} + +// 특정 UTC 시점의 타임존 오프셋 분 단위 계산 +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); +} + +// 타임존 기준 로컬 날짜 시간의 UTC Date 변환 +export 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; + + // 첫 번째 계산은 로컬 시간을 그대로 UTC라고 가정한 임시값(localAsUTC) 기준 오프셋 조회 + // 하지만 실제로 필요한 오프셋은 해당 로컬 시각이 대응하는 UTC 시점의 값이므로 1차 결과만으로는 + // DST(일광 절약 시간) 경계 전후 중 어느 구간 오프셋을 참조했는지 확정할 수 없음 + // 따라서 1차 보정으로 얻은 UTC 기준 오프셋을 다시 조회해 한 번 더 계산하는 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); +} + +// 주어진 로컬 날짜에 대한 일 수 가산 결과 반환 +export 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() + }; +} + +// 타임존 기준 Date의 yyyy-MM-dd 형태 키 문자열 변환 +export 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((part) => [part.type, part.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"}`; +} + +// Firestore Timestamp 또는 Date 값을 일반 Date로 변환 +export function toDate(value: unknown): Date | null { + if (value instanceof admin.firestore.Timestamp) { + return value.toDate(); + } + + if (value instanceof Date) { + return value; + } + + return null; +} diff --git a/Firebase/functions/src/common/error.ts b/Firebase/functions/src/common/error.ts index a0229bcc..4cd84fac 100644 --- a/Firebase/functions/src/common/error.ts +++ b/Firebase/functions/src/common/error.ts @@ -1,7 +1,13 @@ export function normalizeError(error: unknown): Record { - const normalized = error as {code?: unknown; message?: unknown; stack?: unknown}; + const normalized = error as { + code?: unknown; + details?: unknown; + message?: unknown; + stack?: unknown; + }; return { code: normalized?.code ?? null, + details: normalized?.details ?? null, message: normalized?.message ?? String(error), stack: normalized?.stack ?? null }; diff --git a/Firebase/functions/src/common/firestorePath.ts b/Firebase/functions/src/common/firestorePath.ts new file mode 100644 index 00000000..dbe9de4d --- /dev/null +++ b/Firebase/functions/src/common/firestorePath.ts @@ -0,0 +1,59 @@ +export namespace FirestorePath { + enum Collection { + users = "users", + userData = "userData", + todoLists = "todoLists", + notifications = "notifications", + notificationDispatches = "notificationDispatches", + webPages = "webPages" + } + + export enum UserDataDocument { + info = "info", + tokens = "tokens", + settings = "settings", + categories = "categories" + } + + export function user(userId: string): string { + return `${Collection.users}/${userId}`; + } + + export function userData(userId: string, document?: UserDataDocument): string { + const path = `${user(userId)}/${Collection.userData}`; + if (!document) { return path; } + return `${path}/${document}`; + } + + export function todos(userId: string): string { + return `${user(userId)}/${Collection.todoLists}`; + } + + export function todo(userId: string, todoId: string): string { + return `${todos(userId)}/${todoId}`; + } + + export function notifications(userId: string): string { + return `${user(userId)}/${Collection.notifications}`; + } + + export function notification(userId: string, notificationId: string): string { + return `${notifications(userId)}/${notificationId}`; + } + + export function notificationDispatches(userId: string): string { + return `${user(userId)}/${Collection.notificationDispatches}`; + } + + export function notificationDispatch(userId: string, dispatchId: string): string { + return `${notificationDispatches(userId)}/${dispatchId}`; + } + + export function webPages(userId: string): string { + return `${user(userId)}/${Collection.webPages}`; + } + + export function webPage(userId: string, documentId: string): string { + return `${webPages(userId)}/${documentId}`; + } +} diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index b51d01ef..99232ead 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -1,12 +1,14 @@ import { onTaskDispatched } from "firebase-functions/v2/tasks"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; +import { formatDateKey, toDate } from "../common/date"; +import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; import { resolveTimeZone } from "./shared"; type TaskPayload = { userId: string; todoId: string; - todoCategory: string; dueDateKey: string; title: string; body: string; @@ -16,7 +18,7 @@ type FirestoreErrorLike = { code?: unknown; }; -// Cloud Tasks에 의해 트리거되는 함수 +// 큐에 적재된 알림 payload 검증 및 실제 푸시 발송 수행 export const sendPushNotification = onTaskDispatched({ maxInstances: 2, region: "asia-northeast3", @@ -24,29 +26,18 @@ export const sendPushNotification = onTaskDispatched({ rateLimits: { maxDispatchesPerSecond: 200 }, }, async (req) => { - const taskId = req.data?.taskId; - if (!isValidTaskId(taskId)) { + const parsed = parseTaskPayload(req.data); + if (!parsed) { 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, dueDateKey, title, body } = parsed; - const settingsDocRef = admin.firestore().doc(`users/${userId}/userData/settings`); - const todoDocRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const settingsDocRef = admin.firestore() + .doc(FirestorePath.userData(userId, FirestorePath.UserDataDocument.settings)); + const todoDocRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); const [settingsDoc, todoDoc] = await Promise.all([ settingsDocRef.get(), todoDocRef.get() @@ -62,26 +53,18 @@ export const sendPushNotification = onTaskDispatched({ const timeZone = resolveTimeZone(settingsData); - const dueDateValue = todoData.dueDate; - const currentDueDate = dueDateValue instanceof admin.firestore.Timestamp ? - dueDateValue.toDate() : - dueDateValue instanceof Date ? - dueDateValue : - null; + const currentDueDate = toDate(todoData.dueDate); 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 dispatchDocRef = admin.firestore().doc(FirestorePath.notificationDispatch(userId, id)); + const notificationDocRef = admin.firestore().doc(FirestorePath.notification(userId, id)); try { - await receiptDocRef.create({ + await dispatchDocRef.create({ todoId, - dueDateKey, - createdAt: admin.firestore.FieldValue.serverTimestamp() + dueDateKey }); } catch (error) { if (isAlreadyExistsError(error)) { @@ -95,6 +78,7 @@ export const sendPushNotification = onTaskDispatched({ body, receivedAt: admin.firestore.FieldValue.serverTimestamp(), isRead: false, + isDeleted: false, todoId: todoId, todoCategory: todoCategory }; @@ -102,12 +86,14 @@ export const sendPushNotification = onTaskDispatched({ // 1. 사용자 FCM 토큰과 읽지 않은 알림 수 가져오기 const unreadCountPromise = admin.firestore() - .collection(`users/${userId}/notifications`) + .collection(FirestorePath.notifications(userId)) .where("isRead", "==", false) .count() .get(); // 2. 사용자 FCM 토큰 가져오기 - const tokenDocPromise = admin.firestore().doc(`users/${userId}/userData/tokens`).get(); + const tokenDocPromise = admin.firestore() + .doc(FirestorePath.userData(userId, FirestorePath.UserDataDocument.tokens)) + .get(); const [tokenDoc, unreadCountSnapshot] = await Promise.all([ tokenDocPromise, unreadCountPromise @@ -146,31 +132,18 @@ export const sendPushNotification = onTaskDispatched({ } catch (error) { logger.error("알림 발송 중 오류 발생", { - taskId, - error + payload: req.data, + error: normalizeError(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); -} - +// 큐 payload의 발송 필수 필드 충족 여부 검증 function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): TaskPayload | null { const { userId, todoId, - todoCategory, dueDateKey, title, body @@ -179,7 +152,6 @@ function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): Tas if ( typeof userId !== "string" || typeof todoId !== "string" || - typeof todoCategory !== "string" || typeof dueDateKey !== "string" || typeof title !== "string" || typeof body !== "string" @@ -194,38 +166,14 @@ function parseTaskPayload(data: FirebaseFirestore.DocumentData | undefined): Tas return { userId, todoId, - todoCategory, dueDateKey, title, body }; } +// Firestore create 충돌의 기존 문서 존재 여부 판별 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"}`; -} diff --git a/Firebase/functions/src/fcm/schedule.ts b/Firebase/functions/src/fcm/schedule.ts index 1ce79c00..54f45c50 100644 --- a/Firebase/functions/src/fcm/schedule.ts +++ b/Firebase/functions/src/fcm/schedule.ts @@ -2,6 +2,9 @@ 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 { addDays, getZonedParts, zonedDateTimeToUTC } from "../common/date"; +import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; import { resolveTimeZone } from "./shared"; const LOCATION = "asia-northeast3"; @@ -9,21 +12,7 @@ const DEFAULT_HOUR = 9; const DEFAULT_MINUTE = 0; const MINUTE_INTERVAL = 5; -type ZonedDateParts = { - year: number; - month: number; - day: number; - hour: number; - minute: number; -}; - -type ErrorLike = { - code?: unknown; - details?: unknown; - message?: unknown; - stack?: unknown; -}; - +// 사용자별 설정 시간에 맞춘 내일 마감 Todo 알림 작업 큐 적재 export const scheduleTodoReminder = onSchedule({ maxInstances: 1, region: LOCATION, @@ -40,7 +29,7 @@ export const scheduleTodoReminder = onSchedule({ } catch (error) { logger.error("users 조회 실패", { at: "collection(users).get()", - ...serializeError(error) + ...normalizeError(error) }); return; } @@ -49,12 +38,14 @@ export const scheduleTodoReminder = onSchedule({ const userId = userDoc.id; let settingsDoc: FirebaseFirestore.DocumentSnapshot; try { - settingsDoc = await admin.firestore().doc(`users/${userId}/userData/settings`).get(); + settingsDoc = await admin.firestore() + .doc(FirestorePath.userData(userId, FirestorePath.UserDataDocument.settings)) + .get(); } catch (error) { logger.error("settings 조회 실패", { userId, at: "users/{uid}/userData/settings", - ...serializeError(error) + ...normalizeError(error) }); continue; } @@ -97,7 +88,7 @@ export const scheduleTodoReminder = onSchedule({ let todosSnapshot: FirebaseFirestore.QuerySnapshot; try { todosSnapshot = await admin.firestore() - .collection(`users/${userId}/todoLists`) + .collection(FirestorePath.todos(userId)) .where("dueDate", ">=", admin.firestore.Timestamp.fromDate(startUTC)) .where("dueDate", "<", admin.firestore.Timestamp.fromDate(endUTC)) .get(); @@ -108,7 +99,7 @@ export const scheduleTodoReminder = onSchedule({ startUTC: startUTC.toISOString(), endUTC: endUTC.toISOString(), dueDateKey, - ...serializeError(error) + ...normalizeError(error) }); continue; } @@ -118,138 +109,30 @@ export const scheduleTodoReminder = onSchedule({ const todoTitle = typeof todoData.title === "string" && todoData.title.trim() ? todoData.title : "제목 없음"; - const todoCategory = typeof todoData.category === "string" && todoData.category.trim() ? - todoData.category : - "etc"; - const notificationTaskRef = admin.firestore().collection("notificationTasks").doc(); - const notificationTaskData = { + const notificationPayload = { userId, todoId: todoDoc.id, - todoCategory, dueDateKey, title: "DevLog", - body: `'${todoTitle}'의 마감일이 내일입니다.`, - createdAt: admin.firestore.FieldValue.serverTimestamp() + body: `'${todoTitle}'의 마감일이 내일입니다.` }; try { - await notificationTaskRef.set(notificationTaskData); - await queue.enqueue({ taskId: notificationTaskRef.id }); + await queue.enqueue(notificationPayload); } 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) + ...normalizeError(error) }); } } } } catch (error) { - logger.error("알림 스케줄 배치 실행 중 오류 발생", serializeError(error)); + logger.error("알림 스케줄 배치 실행 중 오류 발생", normalizeError(error)); } } ); - -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", { - 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 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() - }; -} diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index bc543223..9c315f5d 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -35,6 +35,7 @@ import { import { removeTodoNotificationDocuments, removeCompletedTodoNotificationRecords, + cleanupSoftDeletedTodos, cleanupUnusedTodoNotificationRecords } from "./todo/cleanup"; @@ -59,12 +60,20 @@ import { completePushNotificationDeletion } from "./notification/deletion"; +import { + cleanupSoftDeletedNotifications +} from "./notification/cleanup"; + import { requestWebPageDeletion, undoWebPageDeletion, completeWebPageDeletion } from "./webPage/deletion"; +import { + cleanupSoftDeletedWebPages +} from "./webPage/cleanup"; + // .env 파일 로드 dotenv.config({ @@ -104,6 +113,7 @@ export { export { removeTodoNotificationDocuments, removeCompletedTodoNotificationRecords, + cleanupSoftDeletedTodos, cleanupUnusedTodoNotificationRecords, syncTodoNotificationCategory, requestMoveRemovedCategoryTodosToEtc, @@ -114,7 +124,9 @@ export { requestPushNotificationDeletion, undoPushNotificationDeletion, completePushNotificationDeletion, + cleanupSoftDeletedNotifications, requestWebPageDeletion, undoWebPageDeletion, - completeWebPageDeletion + completeWebPageDeletion, + cleanupSoftDeletedWebPages }; diff --git a/Firebase/functions/src/notification/cleanup.ts b/Firebase/functions/src/notification/cleanup.ts new file mode 100644 index 00000000..fccef6a0 --- /dev/null +++ b/Firebase/functions/src/notification/cleanup.ts @@ -0,0 +1,40 @@ +import { onSchedule } from "firebase-functions/v2/scheduler"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const CLEANUP_BATCH_SIZE = 200; + +export const cleanupSoftDeletedNotifications = onSchedule({ + maxInstances: 1, + region: LOCATION, + schedule: "0 0 * * *", + timeZone: "UTC" + }, + async () => { + try { + while (true) { + const snapshot = await admin.firestore() + .collectionGroup("notifications") + .where("isDeleted", "==", true) + .limit(CLEANUP_BATCH_SIZE) + .get(); + + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); + + if (snapshot.size < CLEANUP_BATCH_SIZE) { return; } + } + } catch (error) { + logger.error("soft delete Notification cleanup 실패", { + error: normalizeError(error) + }); + } + } +); diff --git a/Firebase/functions/src/notification/deletion.ts b/Firebase/functions/src/notification/deletion.ts index 95b1f66e..ddaee980 100644 --- a/Firebase/functions/src/notification/deletion.ts +++ b/Firebase/functions/src/notification/deletion.ts @@ -4,14 +4,14 @@ import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; const DELETE_DELAY_SECONDS = 5; -type NotificationDeletionTaskData = { +type NotificationDeletionPayload = { userId: string; notificationId: string; - createdAt?: FirebaseFirestore.Timestamp | Date | null; }; export const requestPushNotificationDeletion = onCall({ @@ -33,48 +33,30 @@ export const requestPushNotificationDeletion = onCall({ throw new HttpsError("invalid-argument", "notificationId가 필요합니다."); } - const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + const notificationRef = admin.firestore().doc(FirestorePath.notification(userId, notificationId)); const notificationSnapshot = await notificationRef.get(); - if (!notificationSnapshot.exists) { + if (!notificationSnapshot.exists || notificationSnapshot.data()?.isDeleted === true) { throw new HttpsError("not-found", "Notification을 찾을 수 없습니다."); } - const taskRef = admin.firestore().collection("notificationDeletionTasks").doc(); - const taskData = { - userId, - notificationId, - createdAt: admin.firestore.FieldValue.serverTimestamp() - }; - try { - await taskRef.set(taskData); await notificationRef.set({ // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp() + deletingAt: admin.firestore.FieldValue.serverTimestamp(), + isDeleted: false }, {merge: true}); const queue = getFunctions().taskQueue( `locations/${LOCATION}/functions/completePushNotificationDeletion` ); await queue.enqueue( - {taskId: taskRef.id}, + { userId, notificationId }, {scheduleDelaySeconds: DELETE_DELAY_SECONDS} ); } catch (error) { - try { - await taskRef.delete(); - } catch (cleanupError) { - logger.warn("notificationDeletionTasks 정리 실패", { - userId, - notificationId, - taskId: taskRef.id, - error: normalizeError(cleanupError) - }); - } - const currentNotificationSnapshot = await notificationRef.get(); - if (currentNotificationSnapshot.exists) { + if (currentNotificationSnapshot.exists && currentNotificationSnapshot.data()?.isDeleted !== true) { await notificationRef.update({ deletingAt: admin.firestore.FieldValue.delete() }); @@ -110,28 +92,15 @@ export const undoPushNotificationDeletion = onCall({ if (!notificationId) { throw new HttpsError("invalid-argument", "notificationId가 필요합니다."); } - - const taskSnapshot = await admin.firestore() - .collection("notificationDeletionTasks") - .where("userId", "==", userId) - .where("notificationId", "==", notificationId) - .get(); - const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + const notificationRef = admin.firestore().doc(FirestorePath.notification(userId, notificationId)); try { const notificationSnapshot = await notificationRef.get(); - if (notificationSnapshot.exists) { + if (notificationSnapshot.exists && notificationSnapshot.data()?.isDeleted !== true) { await notificationRef.update({ - deletingAt: admin.firestore.FieldValue.delete() - }); - } - - if (!taskSnapshot.empty) { - const batch = admin.firestore().batch(); - taskSnapshot.docs.forEach((document) => { - batch.delete(document.ref); + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: false }); - await batch.commit(); } } catch (error) { logger.error("푸시 알림 삭제 취소 실패", { @@ -153,45 +122,54 @@ export const completePushNotificationDeletion = onTaskDispatched({ rateLimits: {maxDispatchesPerSecond: 200}, }, async (request) => { - const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; - if (!taskId) { + const payload = parseDeletionPayload(request.data); + if (!payload) { logger.warn("유효하지 않은 푸시 알림 삭제 payload", request.data); return; } - const taskRef = admin.firestore().collection("notificationDeletionTasks").doc(taskId); - const taskSnapshot = await taskRef.get(); - if (!taskSnapshot.exists) { return; } + const { userId, notificationId } = payload; - const taskData = taskSnapshot.data() as NotificationDeletionTaskData | undefined; - const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; - const notificationId = typeof taskData?.notificationId === "string" ? taskData.notificationId : ""; - if (!userId || !notificationId) { - logger.warn("notificationDeletionTasks 문서 형식이 올바르지 않습니다.", {taskId}); - return; - } - - const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + const notificationRef = admin.firestore().doc(FirestorePath.notification(userId, notificationId)); try { const notificationSnapshot = await notificationRef.get(); const deletingAt = notificationSnapshot.data()?.deletingAt; + const isDeleted = notificationSnapshot.data()?.isDeleted === true; - if (!notificationSnapshot.exists || !deletingAt) { - await taskRef.delete(); + if (!notificationSnapshot.exists || !deletingAt || isDeleted) { return; } - await notificationRef.delete(); - await taskRef.delete(); + await notificationRef.set({ + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: true + }, { merge: true }); } catch (error) { logger.error("푸시 알림 최종 삭제 실패", { userId, notificationId, - taskId, error: normalizeError(error) }); throw error; } } ); + +function parseDeletionPayload(data: unknown): NotificationDeletionPayload | null { + const userId = typeof (data as NotificationDeletionPayload | undefined)?.userId === "string" ? + (data as NotificationDeletionPayload).userId.trim() : + ""; + const notificationId = typeof (data as NotificationDeletionPayload | undefined)?.notificationId === "string" ? + (data as NotificationDeletionPayload).notificationId.trim() : + ""; + + if (!userId || !notificationId) { + return null; + } + + return { + userId, + notificationId + }; +} diff --git a/Firebase/functions/src/todo/cleanup.ts b/Firebase/functions/src/todo/cleanup.ts index fcc1031c..ef0c971e 100644 --- a/Firebase/functions/src/todo/cleanup.ts +++ b/Firebase/functions/src/todo/cleanup.ts @@ -2,11 +2,15 @@ import { onDocumentDeleted, onDocumentUpdated } from "firebase-functions/v2/fire import { onSchedule } from "firebase-functions/v2/scheduler"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; +import { toDate } from "../common/date"; +import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; const DELETE_BATCH_SIZE = 200; const QUERY_BATCH_SIZE = 100; +// Todo 삭제 시 연결된 알림 문서와 발송 기록 문서의 동시 제거 export const removeTodoNotificationDocuments = onDocumentDeleted({ maxInstances: 1, document: "users/{userId}/todoLists/{todoId}", @@ -17,18 +21,19 @@ export const removeTodoNotificationDocuments = onDocumentDeleted({ const todoId = event.params.todoId; try { - await deleteByTodoId(userId, "notificationReceipts", todoId); + await deleteByTodoId(userId, "notificationDispatches", todoId); await deleteByTodoId(userId, "notifications", todoId); } catch (error) { logger.error("todo 삭제 후 notification 문서 정리 실패", { userId, todoId, - error + error: normalizeError(error) }); } } ); +// 지난 마감일 Todo 완료 시 재발송 방지 기록 정리 export const removeCompletedTodoNotificationRecords = onDocumentUpdated({ maxInstances: 1, document: "users/{userId}/todoLists/{todoId}", @@ -43,110 +48,154 @@ export const removeCompletedTodoNotificationRecords = onDocumentUpdated({ if (!beforeData || !afterData) { return; } if (beforeData.isCompleted === true || afterData.isCompleted !== true) { return; } - const dueDateValue = afterData.dueDate; - let dueDate: Date | null = null; - if (dueDateValue instanceof admin.firestore.Timestamp) { - dueDate = dueDateValue.toDate(); - } else if (dueDateValue instanceof Date) { - dueDate = dueDateValue; - } + const dueDate = toDate(afterData.dueDate); if (!dueDate || Date.now() <= dueDate.getTime()) { return; } try { - await deleteByTodoId(userId, "notificationReceipts", todoId); + await deleteByTodoId(userId, "notificationDispatches", todoId); } catch (error) { logger.error("완료된 todo의 notification record 정리 실패", { userId, todoId, - error + error: normalizeError(error) }); } } ); -export const cleanupUnusedTodoNotificationRecords = onSchedule({ +// soft delete Todo 문서의 실제 삭제 +export const cleanupSoftDeletedTodos = onSchedule({ maxInstances: 1, region: LOCATION, - schedule: "0 * * * *", + schedule: "0 0 * * *", timeZone: "UTC" }, async () => { try { - let lastExpiredCompletedTodo: + let lastDocument: FirebaseFirestore.QueryDocumentSnapshot | undefined; while (true) { let query = admin.firestore() .collectionGroup("todoLists") - .where("isCompleted", "==", true) - .where("dueDate", "<", admin.firestore.Timestamp.now()) - .orderBy("dueDate") + .where("isDeleted", "==", true) + .orderBy(admin.firestore.FieldPath.documentId()) .limit(QUERY_BATCH_SIZE); - if (lastExpiredCompletedTodo) { - query = query.startAfter(lastExpiredCompletedTodo); + if (lastDocument) { + query = query.startAfter(lastDocument); } const snapshot = await query.get(); - if (snapshot.empty) { break; } + if (snapshot.empty) { return; } - for (const todoDoc of snapshot.docs) { - const userId = todoDoc.ref.parent.parent?.id; - if (!userId) { continue; } + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); - await deleteByTodoId(userId, "notificationReceipts", todoDoc.id); - } - - if (snapshot.size < QUERY_BATCH_SIZE) { break; } - lastExpiredCompletedTodo = snapshot.docs[snapshot.docs.length - 1]; + if (snapshot.size < QUERY_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("지난 마감일의 완료된 todo notification record 정리 실패", { error }); + logger.error("soft delete Todo cleanup 실패", { + error: normalizeError(error) + }); } + } +); +// 사용되지 않는 알림 발송 기록의 주기적 정리 +export const cleanupUnusedTodoNotificationRecords = onSchedule({ + maxInstances: 1, + region: LOCATION, + schedule: "0 * * * *", + timeZone: "UTC" + }, + async () => { try { - let lastTodoWithoutDueDate: - FirebaseFirestore.QueryDocumentSnapshot | undefined; - - while (true) { + await cleanupDispatchesByTodoQuery((lastDocument) => { let query = admin.firestore() .collectionGroup("todoLists") - .where("dueDate", "==", null) - .orderBy(admin.firestore.FieldPath.documentId()) + .where("isCompleted", "==", true) + .where("dueDate", "<", admin.firestore.Timestamp.now()) + .orderBy("dueDate") .limit(QUERY_BATCH_SIZE); - if (lastTodoWithoutDueDate) { - query = query.startAfter(lastTodoWithoutDueDate); + if (lastDocument) { + query = query.startAfter(lastDocument); } - const snapshot = await query.get(); - if (snapshot.empty) { break; } + return query; + }); + } catch (error) { + logger.error("지난 마감일의 완료된 todo notification record 정리 실패", { + error: normalizeError(error) + }); + } - for (const todoDoc of snapshot.docs) { - const userId = todoDoc.ref.parent.parent?.id; - if (!userId) { continue; } + try { + await cleanupDispatchesByTodoQuery((lastDocument) => { + let query = admin.firestore() + .collectionGroup("todoLists") + .where("dueDate", "==", null) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(QUERY_BATCH_SIZE); - await deleteByTodoId(userId, "notificationReceipts", todoDoc.id); + if (lastDocument) { + query = query.startAfter(lastDocument); } - if (snapshot.size < QUERY_BATCH_SIZE) { break; } - lastTodoWithoutDueDate = snapshot.docs[snapshot.docs.length - 1]; - } + return query; + }); } catch (error) { - logger.error("마감일이 없는 todo notification record 정리 실패", { error }); + logger.error("마감일이 없는 todo notification record 정리 실패", { + error: normalizeError(error) + }); } } ); +// Todo 조회 쿼리를 순회하며 연결된 알림 발송 기록을 정리 +async function cleanupDispatchesByTodoQuery( + makeQuery: ( + lastDocument?: + FirebaseFirestore.QueryDocumentSnapshot + ) => FirebaseFirestore.Query +): Promise { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + + while (true) { + const snapshot = await makeQuery(lastDocument).get(); + if (snapshot.empty) { return; } + + for (const todoDoc of snapshot.docs) { + const userId = todoDoc.ref.parent.parent?.id; + if (!userId) { continue; } + + await deleteByTodoId(userId, "notificationDispatches", todoDoc.id); + } + + if (snapshot.size < QUERY_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; + } +} + +// 특정 Todo 연결 문서의 배치 단위 전체 삭제 async function deleteByTodoId( userId: string, - collectionName: "notificationReceipts" | "notifications", + collectionName: "notificationDispatches" | "notifications", todoId: string ): Promise { while (true) { + const collectionPath = collectionName === "notificationDispatches" ? + FirestorePath.notificationDispatches(userId) : + FirestorePath.notifications(userId); const snapshot = await admin.firestore() - .collection(`users/${userId}/${collectionName}`) + .collection(collectionPath) .where("todoId", "==", todoId) .limit(DELETE_BATCH_SIZE) .get(); diff --git a/Firebase/functions/src/todo/deletion.ts b/Firebase/functions/src/todo/deletion.ts index b62cf491..c66a57a3 100644 --- a/Firebase/functions/src/todo/deletion.ts +++ b/Firebase/functions/src/todo/deletion.ts @@ -3,16 +3,16 @@ import {onTaskDispatched} from "firebase-functions/v2/tasks"; import {getFunctions} from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; +import { FirestorePath } from "../common/firestorePath"; import {normalizeError} from "../common/error"; const LOCATION = "asia-northeast3"; const DELETE_DELAY_SECONDS = 5; const QUERY_BATCH_SIZE = 200; -type TodoDeletionTaskData = { +type TodoDeletionPayload = { userId: string; todoId: string; - createdAt?: FirebaseFirestore.Timestamp | Date | null; }; export const requestTodoDeletion = onCall({ @@ -32,65 +32,54 @@ export const requestTodoDeletion = onCall({ throw new HttpsError("invalid-argument", "todoId가 필요합니다."); } - const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); const todoSnapshot = await todoRef.get(); - if (!todoSnapshot.exists) { + if (!todoSnapshot.exists || todoSnapshot.data()?.isDeleted === true) { throw new HttpsError("not-found", "Todo를 찾을 수 없습니다."); } - const taskRef = admin.firestore().collection("todoDeletionTasks").doc(); - const taskData = { - userId, - todoId, - createdAt: admin.firestore.FieldValue.serverTimestamp() - }; - try { - await taskRef.set(taskData); await todoRef.set({ - // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp() + // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 soft delete 되기 전 상태를 의미한다. + deletingAt: admin.firestore.FieldValue.serverTimestamp(), + isDeleted: false }, {merge: true}); - await updateNotificationsDeletingAt( + await updateNotificationsDeletionState( userId, todoId, - admin.firestore.FieldValue.serverTimestamp() + { + deletingAt: admin.firestore.FieldValue.serverTimestamp(), + isDeleted: false + } ); const queue = getFunctions().taskQueue( `locations/${LOCATION}/functions/completeTodoDeletion` ); await queue.enqueue( - {taskId: taskRef.id}, - {scheduleDelaySeconds: DELETE_DELAY_SECONDS} + { userId, todoId }, + { scheduleDelaySeconds: DELETE_DELAY_SECONDS } ); } catch (error) { - try { - await taskRef.delete(); - } catch (cleanupError) { - logger.warn("todoDeletionTasks 정리 실패", { - userId, - todoId, - taskId: taskRef.id, - error: normalizeError(cleanupError) - }); - } - - const todoSnapshot = await todoRef.get(); + const currentTodoSnapshot = await todoRef.get(); - if (todoSnapshot.exists) { + if (currentTodoSnapshot.exists && currentTodoSnapshot.data()?.isDeleted !== true) { await todoRef.update({ deletingAt: admin.firestore.FieldValue.delete() }); } - await updateNotificationsDeletingAt( + await updateNotificationsDeletionState( userId, todoId, - admin.firestore.FieldValue.delete() + { + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: false + } ); + logger.error("todo 삭제 요청 실패", { userId, todoId, @@ -120,35 +109,25 @@ export const undoTodoDeletion = onCall({ throw new HttpsError("invalid-argument", "todoId가 필요합니다."); } - const taskSnapshot = await admin.firestore() - .collection("todoDeletionTasks") - .where("userId", "==", userId) - .where("todoId", "==", todoId) - .get(); - try { - const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); const todoSnapshot = await todoRef.get(); - if (todoSnapshot.exists) { + if (todoSnapshot.exists && todoSnapshot.data()?.isDeleted !== true) { await todoRef.update({ - deletingAt: admin.firestore.FieldValue.delete() + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: false }); } - await updateNotificationsDeletingAt( + await updateNotificationsDeletionState( userId, todoId, - admin.firestore.FieldValue.delete() + { + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: false + } ); - - if (!taskSnapshot.empty) { - const batch = admin.firestore().batch(); - taskSnapshot.docs.forEach((document) => { - batch.delete(document.ref); - }); - await batch.commit(); - } } catch (error) { logger.error("todo 삭제 취소 실패", { userId, @@ -169,42 +148,42 @@ export const completeTodoDeletion = onTaskDispatched({ rateLimits: {maxDispatchesPerSecond: 200}, }, async (request) => { - const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; - if (!taskId) { + const payload = parseDeletionPayload(request.data); + if (!payload) { logger.warn("유효하지 않은 todo 삭제 payload", request.data); return; } - const taskRef = admin.firestore().collection("todoDeletionTasks").doc(taskId); - const taskSnapshot = await taskRef.get(); - if (!taskSnapshot.exists) { return; } - - const taskData = taskSnapshot.data() as TodoDeletionTaskData | undefined; - const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; - const todoId = typeof taskData?.todoId === "string" ? taskData.todoId : ""; - if (!userId || !todoId) { - logger.warn("todoDeletionTasks 문서 형식이 올바르지 않습니다.", {taskId}); - return; - } - - const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const { userId, todoId } = payload; + const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); try { const todoSnapshot = await todoRef.get(); const deletingAt = todoSnapshot.data()?.deletingAt; + const isDeleted = todoSnapshot.data()?.isDeleted === true; - if (!todoSnapshot.exists || !deletingAt) { - await taskRef.delete(); + if (!todoSnapshot.exists || !deletingAt || isDeleted) { return; } - await todoRef.delete(); - await taskRef.delete(); + await todoRef.set({ + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: true, + updatedAt: admin.firestore.FieldValue.serverTimestamp() + }, {merge: true}); + + await updateNotificationsDeletionState( + userId, + todoId, + { + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: true + } + ); } catch (error) { - logger.error("todo 최종 삭제 실패", { + logger.error("todo 최종 soft delete 실패", { userId, todoId, - taskId, error: normalizeError(error) }); throw error; @@ -212,28 +191,52 @@ export const completeTodoDeletion = onTaskDispatched({ } ); -async function updateNotificationsDeletingAt( +function parseDeletionPayload(data: unknown): TodoDeletionPayload | null { + const userId = typeof (data as TodoDeletionPayload | undefined)?.userId === "string" ? + (data as TodoDeletionPayload).userId.trim() : + ""; + const todoId = typeof (data as TodoDeletionPayload | undefined)?.todoId === "string" ? + (data as TodoDeletionPayload).todoId.trim() : + ""; + + if (!userId || !todoId) { + return null; + } + + return { + userId, + todoId + }; +} + +async function updateNotificationsDeletionState( userId: string, todoId: string, - fieldValue: FirebaseFirestore.FieldValue + data: { [key: string]: FirebaseFirestore.FieldValue | boolean } ): Promise { + let lastDocument: FirebaseFirestore.QueryDocumentSnapshot | undefined + while (true) { - const snapshot = await admin.firestore() - .collection(`users/${userId}/notifications`) + let query = admin.firestore() + .collection(FirestorePath.notifications(userId)) .where("todoId", "==", todoId) + .orderBy(admin.firestore.FieldPath.documentId()) .limit(QUERY_BATCH_SIZE) - .get(); + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); if (snapshot.empty) { return; } const batch = admin.firestore().batch(); snapshot.docs.forEach((document) => { - batch.update(document.ref, { - deletingAt: fieldValue - }); + batch.update(document.ref, data); }); await batch.commit(); if (snapshot.size < QUERY_BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } diff --git a/Firebase/functions/src/todo/update.ts b/Firebase/functions/src/todo/update.ts index d08996ea..e60555f8 100644 --- a/Firebase/functions/src/todo/update.ts +++ b/Firebase/functions/src/todo/update.ts @@ -2,10 +2,12 @@ import { onDocumentUpdated } from "firebase-functions/v2/firestore"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; const BATCH_SIZE = 200; +// Todo 카테고리 변경 시 기존 알림 문서 카테고리 동기화 export const syncTodoNotificationCategory = onDocumentUpdated({ maxInstances: 1, document: "users/{userId}/todoLists/{todoId}", @@ -25,10 +27,7 @@ export const syncTodoNotificationCategory = onDocumentUpdated({ } try { - await Promise.all([ - updateNotifications(userId, todoId, afterCategory), - updateNotificationTasks(userId, todoId, afterCategory) - ]); + await updateNotifications(userId, todoId, afterCategory); } catch (error) { logger.error("todo 카테고리 변경 후 알림 데이터 동기화 실패", { userId, @@ -42,6 +41,7 @@ export const syncTodoNotificationCategory = onDocumentUpdated({ } ); +// 변경된 카테고리 값의 해당 Todo 알림 문서 반영 async function updateNotifications( userId: string, todoId: string, @@ -50,14 +50,7 @@ async function updateNotifications( await updateNotificationBatch(userId, todoId, todoCategory) } -async function updateNotificationTasks( - userId: string, - todoId: string, - todoCategory: string -): Promise { - await updateNotificationTaskBatch(userId, todoId, todoCategory) -} - +// 알림 문서의 배치 단위 순회 및 카테고리 값 갱신 async function updateNotificationBatch( userId: string, todoId: string, @@ -66,7 +59,7 @@ async function updateNotificationBatch( FirebaseFirestore.QueryDocumentSnapshot ): Promise { let query = admin.firestore() - .collection(`users/${userId}/notifications`) + .collection(FirestorePath.notifications(userId)) .where("todoId", "==", todoId) .orderBy(admin.firestore.FieldPath.documentId()) .limit(BATCH_SIZE); @@ -93,40 +86,3 @@ async function updateNotificationBatch( snapshot.docs[snapshot.docs.length - 1] ); } - -async function updateNotificationTaskBatch( - userId: string, - todoId: string, - todoCategory: string, - lastDocument?: - FirebaseFirestore.QueryDocumentSnapshot -): Promise { - let query = admin.firestore() - .collection("notificationTasks") - .where("userId", "==", userId) - .where("todoId", "==", todoId) - .orderBy(admin.firestore.FieldPath.documentId()) - .limit(BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); - } - - const snapshot = await query.get(); - if (snapshot.empty) { return; } - - const batch = admin.firestore().batch(); - snapshot.docs.forEach((document) => { - batch.update(document.ref, { todoCategory }); - }); - await batch.commit(); - - if (snapshot.size < BATCH_SIZE) { return; } - - await updateNotificationTaskBatch( - userId, - todoId, - todoCategory, - snapshot.docs[snapshot.docs.length - 1] - ); -} diff --git a/Firebase/functions/src/todoCategory/update.ts b/Firebase/functions/src/todoCategory/update.ts index 2052c6d1..7944819e 100644 --- a/Firebase/functions/src/todoCategory/update.ts +++ b/Firebase/functions/src/todoCategory/update.ts @@ -4,6 +4,7 @@ import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; const BATCH_SIZE = 200; @@ -17,7 +18,6 @@ type CategoryItem = { type TodoCategoryUpdateTaskData = { userId: string; id: string; - createdAt?: FirebaseFirestore.Timestamp | Date | null; }; export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ @@ -44,28 +44,14 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ ); for (const id of removedIDs) { - const taskRef = admin.firestore().collection("todoCategoryUpdateTasks").doc(); const taskData = { userId, - id, - createdAt: admin.firestore.FieldValue.serverTimestamp() + id }; try { - await taskRef.set(taskData); - await queue.enqueue({ taskId: taskRef.id }); + await queue.enqueue(taskData); } catch (error) { - try { - await taskRef.delete(); - } catch (cleanupError) { - logger.warn("todoCategoryUpdateTasks 정리 실패", { - userId, - id, - taskId: taskRef.id, - error: normalizeError(cleanupError) - }); - } - throw error; } } @@ -87,33 +73,20 @@ export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ rateLimits: { maxDispatchesPerSecond: 20 }, }, async (request) => { - const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; - if (!taskId) { + const taskData = parseTaskPayload(request.data); + if (!taskData) { logger.warn("유효하지 않은 카테고리 정리 payload", request.data); return; } - - const taskRef = admin.firestore().collection("todoCategoryUpdateTasks").doc(taskId); - const taskSnapshot = await taskRef.get(); - if (!taskSnapshot.exists) { return; } - - const taskData = taskSnapshot.data() as TodoCategoryUpdateTaskData | undefined; - const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; - const id = typeof taskData?.id === "string" ? taskData.id : ""; - - if (!userId || !id) { - logger.warn("todoCategoryUpdateTasks 문서 형식이 올바르지 않습니다.", { taskId }); - return; - } + const { userId, id } = taskData; try { await updateTodos(userId, id); - await taskRef.delete(); } catch (error) { logger.error("삭제된 사용자 카테고리 todo 정리 실패", { userId, id, - taskId, + payload: request.data, error: normalizeError(error) }); throw error; @@ -121,6 +94,24 @@ export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ } ); +function parseTaskPayload(data: unknown): TodoCategoryUpdateTaskData | null { + const userId = typeof (data as TodoCategoryUpdateTaskData | undefined)?.userId === "string" ? + (data as TodoCategoryUpdateTaskData).userId.trim() : + ""; + const id = typeof (data as TodoCategoryUpdateTaskData | undefined)?.id === "string" ? + (data as TodoCategoryUpdateTaskData).id.trim() : + ""; + + if (!userId || !id) { + return null; + } + + return { + userId, + id + }; +} + function getRemovedIDs( beforeItems: CategoryItem[], afterItems: CategoryItem[] @@ -155,7 +146,7 @@ async function updateTodoBatch( FirebaseFirestore.QueryDocumentSnapshot ): Promise { let query = admin.firestore() - .collection(`users/${userId}/todoLists`) + .collection(FirestorePath.todos(userId)) .where("category", "==", id) .orderBy(admin.firestore.FieldPath.documentId()) .limit(BATCH_SIZE); diff --git a/Firebase/functions/src/user/delete.ts b/Firebase/functions/src/user/delete.ts index 8cfa69d4..b3ba0751 100644 --- a/Firebase/functions/src/user/delete.ts +++ b/Firebase/functions/src/user/delete.ts @@ -1,6 +1,7 @@ import * as functions from "firebase-functions/v1"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; +import { FirestorePath } from "../common/firestorePath"; export const cleanupDeletedUserFirestoreData = functions .runWith({ @@ -13,7 +14,7 @@ export const cleanupDeletedUserFirestoreData = functions const uid = user.uid; try { - const userDocRef = admin.firestore().doc(`users/${uid}`); + const userDocRef = admin.firestore().doc(FirestorePath.user(uid)); await admin.firestore().recursiveDelete(userDocRef); logger.info("Deleted Firestore user data after Auth user deletion", { uid }); } catch (error) { diff --git a/Firebase/functions/src/webPage/cleanup.ts b/Firebase/functions/src/webPage/cleanup.ts new file mode 100644 index 00000000..2cfc2483 --- /dev/null +++ b/Firebase/functions/src/webPage/cleanup.ts @@ -0,0 +1,40 @@ +import { onSchedule } from "firebase-functions/v2/scheduler"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const CLEANUP_BATCH_SIZE = 200; + +export const cleanupSoftDeletedWebPages = onSchedule({ + maxInstances: 1, + region: LOCATION, + schedule: "0 0 * * *", + timeZone: "UTC" + }, + async () => { + try { + while (true) { + const snapshot = await admin.firestore() + .collectionGroup("webPages") + .where("isDeleted", "==", true) + .limit(CLEANUP_BATCH_SIZE) + .get(); + + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); + + if (snapshot.size < CLEANUP_BATCH_SIZE) { return; } + } + } catch (error) { + logger.error("soft delete WebPage cleanup 실패", { + error: normalizeError(error) + }); + } + } +); diff --git a/Firebase/functions/src/webPage/deletion.ts b/Firebase/functions/src/webPage/deletion.ts index 76470ecc..17b01ffb 100644 --- a/Firebase/functions/src/webPage/deletion.ts +++ b/Firebase/functions/src/webPage/deletion.ts @@ -4,15 +4,14 @@ import { getFunctions } from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { normalizeError } from "../common/error"; +import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; const DELETE_DELAY_SECONDS = 5; -type WebPageDeletionTaskData = { +type WebPageDeletionPayload = { userId: string; urlString: string; - documentPath: string; - createdAt?: FirebaseFirestore.Timestamp | Date | null; }; export const requestWebPageDeletion = onCall({ @@ -35,53 +34,34 @@ export const requestWebPageDeletion = onCall({ } const webPageSnapshot = await admin.firestore() - .collection(`users/${userId}/webPages`) + .collection(FirestorePath.webPages(userId)) .where("url", "==", urlString) .limit(1) .get(); - if (webPageSnapshot.empty) { + if (webPageSnapshot.empty || webPageSnapshot.docs[0].data()?.isDeleted === true) { throw new HttpsError("not-found", "WebPage를 찾을 수 없습니다."); } const webPageRef = webPageSnapshot.docs[0].ref; - const taskRef = admin.firestore().collection("webPageDeletionTasks").doc(); - const taskData = { - userId, - urlString, - documentPath: webPageRef.path, - createdAt: admin.firestore.FieldValue.serverTimestamp() - }; - try { - await taskRef.set(taskData); await webPageRef.set({ - // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp() + // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 soft delete 되기 전 상태를 의미한다. + deletingAt: admin.firestore.FieldValue.serverTimestamp(), + isDeleted: false }, { merge: true }); const queue = getFunctions().taskQueue( `locations/${LOCATION}/functions/completeWebPageDeletion` ); await queue.enqueue( - { taskId: taskRef.id }, + { userId, urlString }, { scheduleDelaySeconds: DELETE_DELAY_SECONDS } ); } catch (error) { - try { - await taskRef.delete(); - } catch (cleanupError) { - logger.warn("webPageDeletionTasks 정리 실패", { - userId, - urlString, - taskId: taskRef.id, - error: normalizeError(cleanupError) - }); - } - const currentWebPageSnapshot = await webPageRef.get(); - if (currentWebPageSnapshot.exists) { + if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted !== true) { await webPageRef.update({ deletingAt: admin.firestore.FieldValue.delete() }); @@ -118,13 +98,8 @@ export const undoWebPageDeletion = onCall({ throw new HttpsError("invalid-argument", "urlString이 필요합니다."); } - const taskSnapshot = await admin.firestore() - .collection("webPageDeletionTasks") - .where("userId", "==", userId) - .where("urlString", "==", urlString) - .get(); const webPageSnapshot = await admin.firestore() - .collection(`users/${userId}/webPages`) + .collection(FirestorePath.webPages(userId)) .where("url", "==", urlString) .limit(1) .get(); @@ -135,19 +110,12 @@ export const undoWebPageDeletion = onCall({ const webPageRef = webPageSnapshot.docs[0].ref; try { - const webPageSnapshot = await webPageRef.get(); - if (webPageSnapshot.exists) { + const currentWebPageSnapshot = await webPageRef.get(); + if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted !== true) { await webPageRef.update({ - deletingAt: admin.firestore.FieldValue.delete() - }); - } - - if (!taskSnapshot.empty) { - const batch = admin.firestore().batch(); - taskSnapshot.docs.forEach((document) => { - batch.delete(document.ref); + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: false }); - await batch.commit(); } } catch (error) { logger.error("웹페이지 삭제 취소 실패", { @@ -169,46 +137,62 @@ export const completeWebPageDeletion = onTaskDispatched({ rateLimits: { maxDispatchesPerSecond: 200 }, }, async (request) => { - const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; - if (!taskId) { + const payload = parseDeletionPayload(request.data); + if (!payload) { logger.warn("유효하지 않은 웹페이지 삭제 payload", request.data); return; } - const taskRef = admin.firestore().collection("webPageDeletionTasks").doc(taskId); - const taskSnapshot = await taskRef.get(); - if (!taskSnapshot.exists) { return; } - - const taskData = taskSnapshot.data() as WebPageDeletionTaskData | undefined; - const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; - const urlString = typeof taskData?.urlString === "string" ? taskData.urlString : ""; - const documentPath = typeof taskData?.documentPath === "string" ? taskData.documentPath : ""; - if (!userId || !urlString || !documentPath) { - logger.warn("webPageDeletionTasks 문서 형식이 올바르지 않습니다.", { taskId }); + const { userId, urlString } = payload; + const webPageSnapshot = await admin.firestore() + .collection(FirestorePath.webPages(userId)) + .where("url", "==", urlString) + .limit(1) + .get(); + if (webPageSnapshot.empty) { return; } - const webPageRef = admin.firestore().doc(documentPath); + const webPageRef = webPageSnapshot.docs[0].ref; try { - const webPageSnapshot = await webPageRef.get(); - const deletingAt = webPageSnapshot.data()?.deletingAt; + const currentWebPageSnapshot = await webPageRef.get(); + const deletingAt = currentWebPageSnapshot.data()?.deletingAt; + const isDeleted = currentWebPageSnapshot.data()?.isDeleted === true; - if (!webPageSnapshot.exists || !deletingAt) { - await taskRef.delete(); + if (!currentWebPageSnapshot.exists || !deletingAt || isDeleted) { return; } - await webPageRef.delete(); - await taskRef.delete(); + await webPageRef.set({ + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: true + }, { merge: true }); } catch (error) { - logger.error("웹페이지 최종 삭제 실패", { + logger.error("웹페이지 최종 soft delete 실패", { userId, urlString, - taskId, error: normalizeError(error) }); throw error; } } ); + +function parseDeletionPayload(data: unknown): WebPageDeletionPayload | null { + const userId = typeof (data as WebPageDeletionPayload | undefined)?.userId === "string" ? + (data as WebPageDeletionPayload).userId.trim() : + ""; + const urlString = typeof (data as WebPageDeletionPayload | undefined)?.urlString === "string" ? + (data as WebPageDeletionPayload).urlString.trim() : + ""; + + if (!userId || !urlString) { + return null; + } + + return { + userId, + urlString + }; +}