Skip to content

Feat/notification#472

Open
hgjwilly-koreatech wants to merge 9 commits into
refactor/homefrom
feat/notification
Open

Feat/notification#472
hgjwilly-koreatech wants to merge 9 commits into
refactor/homefrom
feat/notification

Conversation

@hgjwilly-koreatech

@hgjwilly-koreatech hgjwilly-koreatech commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

#️⃣연관된 이슈

📝작업 내용

이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)

홈화면 리디자인 알림화면 기능 구현 작업입니다.

  1. AppGroup 설정
    Koin과 NotificationService를 하나의 AppGroup으로 설정했습니다.

  2. SwiftData 저장소 만들기
    SwiftData 를 사용했습니다.
    내부적으로 DataContainer를 사용하는 NotificationHistoryService를 만들었습니다.

  3. NotificationService에서 푸시알림을 SwiftData에 저장
    NotificationService의 didReceive에서 푸시알림을 저장합니다.

  4. 알림화면 기능 연결
    알림화면에서 기능을 연결했습니다.

  5. 화면 전환 등
    알림 선택시 화면 전환 등을 마저 구현했습니다.

스크린샷 (선택)

💬리뷰 요구사항(선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

Summary by CodeRabbit

  • New Features

    • Added notification history, including saving incoming notifications and showing them later in the app.
    • You can now mark notifications as read, mark all as read, delete individual items, or clear the full list.
    • Notification taps now open the appropriate destination based on the notification type.
  • Bug Fixes

    • Improved notification loading and refresh behavior.
    • Notifications now keep their read/unread state more consistently across screens.

@hgjwilly-koreatech hgjwilly-koreatech self-assigned this Jul 7, 2026
@hgjwilly-koreatech hgjwilly-koreatech added the FEAT 기능 개발 및 api 연동 label Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1871aec3-c328-483b-a512-2ebd6d1ffe2f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/notification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (3)
Koin/Presentation/NewHome/Notification/NotificationViewModel.swift (1)

96-117: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Silently swallowed mutation errors can desync UI and store.

All four mutation methods discard failures with try?. Since the view/table updates optimistically (delete row, mark read, delete-all) without any reload afterward, a failed persistence write leaves the on-screen state diverged from SwiftData with no user feedback; the discrepancy only surfaces on the next loadNotifications, where deleted/unread rows silently reappear. Consider surfacing a toast (like loadNotifications does) and/or reloading on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Presentation/NewHome/Notification/NotificationViewModel.swift` around
lines 96 - 117, The mutation helpers in NotificationViewModel are swallowing
persistence failures with try?, which can leave the optimistic UI out of sync
with SwiftData. Update deleteNotification(id:), deleteAllNotifications(),
markAsRead(id:), and markAllAsRead() to handle thrown errors explicitly instead
of discarding them, using the existing toast/error pattern from
loadNotifications or by triggering a refresh when a write fails. Ensure the user
gets feedback and the view state is reconciled after any failed call to
deleteNotificationHistoryUseCase or updateNotificationHistoryUseCase.
Koin/Presentation/NewHome/Notification/NotificationViewController.swift (1)

185-188: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Drop the unused shopId here; this route still opens the generic shop list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Presentation/NewHome/Notification/NotificationViewController.swift`
around lines 185 - 188, The navigateToShop(shopId:) method is not using its
shopId parameter because this flow still pushes the generic shop list via
makeShopViewController(). Remove the unused shopId parameter from the method
signature and update any call sites accordingly so the method name and behavior
match the actual navigation.
Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift (1)

11-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Filename no longer matches the type name.

The file is FetchNotificationListUseCase.swift but now defines FetchNotificationHistoryUseCase/DefaultFetchNotificationHistoryUseCase. Consider renaming the file to match for discoverability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift` around lines
11 - 26, The file name no longer matches the types defined inside it. Rename the
file to align with FetchNotificationHistoryUseCase and
DefaultFetchNotificationHistoryUseCase so it is easier to find and maintain the
use case implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Common/Core/NotificationHistoryError.swift`:
- Around line 10-14: NotificationHistoryError currently only conforms to Error,
so error.localizedDescription in NotificationViewModel.loadNotifications() won’t
surface the custom message and the toast falls back to a generic system string.
Update NotificationHistoryError to conform to LocalizedError and make its error
description return the existing message property so the UI shows the intended
text. Keep the change centered on NotificationHistoryError and verify the
calendarDidFail case still exposes "캘린더 오류 발생" through localizedDescription.

In `@Common/NotificationHistory/NotificationHistoryService.swift`:
- Around line 128-145: The cleanup window in
DefaultNotificationHistoryService.deleteExpiredNotifications() is using a 7-day
cutoff, which conflicts with the intended 14-day retention policy. Update the
date calculation in deleteExpiredNotifications() so the expirationDate is based
on 14 days ago, and keep the existing delete/save flow in the MainActor.run
block unchanged.
- Around line 117-125: The deleteAll() method in NotificationHistoryService only
performs container.mainContext.delete(model:) and never persists the change, so
add a save on the same context after the deletion inside the MainActor.run
block, following the pattern used by insert, markAsRead, markAllAsRead, and
delete(messageId:). Ensure the save is awaited and any thrown error is
propagated so the batch deletion is actually committed to the persistent store.
- Around line 34-48: The NotificationHistoryService.insert(record:)
implementation is swallowing persistence failures because `try?` wraps the
`MainActor.run` save path, and unsupported categories are silently ignored in
the `default` branch. Update `insert(record:)` so save errors from
`container.mainContext.save()` are allowed to propagate to callers, and replace
the silent no-op for unsupported categories with an explicit failure path or
validation using the existing `record.category` switch. Keep the fix localized
to `insert(record:)` and the `NotificationRecord` category handling so
`NotificationService.didReceive` can detect dropped or failed inserts.

In `@Common/NotificationHistory/NotificationRecord.swift`:
- Around line 13-21: `NotificationRecord` currently allows duplicate `messageId`
values, which can lead to repeated rows and ambiguous read/delete targeting.
Update the `NotificationRecord` model and any persistence path that creates or
inserts it so `messageId` is enforced as unique, and add deduplication logic
where records are loaded or saved using `messageId` as the key (including the
`NotificationItem.id` mapping) to prevent multiple entries for the same
notification.
- Line 12: Remove Sendable from NotificationRecord since it is a mutable
SwiftData `@Model` class and should not be treated as thread-safe across
Task/MainActor boundaries. Also update NotificationRecord so messageId is
declared as a unique lookup key in the model definition, ensuring markAsRead and
delete operate on a single row and duplicate deliveries cannot create duplicate
records.

In `@Koin/Data/Repository/DefaultNotificationHistoryRepository.swift`:
- Around line 41-43: The public repository API has a naming typo in
DefaultNotificationHistoryRepository: rename makrAllAsRead to markAllAsRead so
it matches the NotificationHistoryRepository protocol and compiles cleanly.
Update the same method name anywhere it is declared or referenced, including the
protocol signature and any conforming implementations, so the async
mark-all-read flow remains consistent.

In `@Koin/Domain/Model/NewHome/NotificationItem.swift`:
- Around line 40-74: The notification mapping in NotificationItem.icon(for:) is
dropping valid AppPath cases because the default branch returns nil, which makes
init?(from:) fail and causes DefaultNotificationHistoryRepository.fetchAll() to
skip persisted records via compactMap. Update the icon mapping so .home, .login,
.club, and .timeTable are either assigned appropriate fallback icons or handled
explicitly, and remove the catch-all default if possible so future AppPath
additions are forced to be mapped at compile time.

In `@Koin/Domain/Repository/NotificationHistoryRepository.swift`:
- Line 15: Rename the misspelled repository API from makrAllAsRead to
markAllAsRead in NotificationHistoryRepository and update every matching
implementation/call site, including DefaultNotificationHistoryRepository and
DefaultUpdateNotificationHistoryUseCase, so the protocol and its uses all share
the corrected method name.

In `@Koin/Presentation/NewHome/Notification/NotificationViewModel.swift`:
- Around line 80-94: The loading reset in
NotificationViewModel.loadNotifications() is happening too early because the
defer runs when the synchronous method returns, not after
fetchNotificationHistoryUseCase.execute() finishes. Move the loading false
cleanup into the Task scope in loadNotifications(), so updateLoading(false) is
sent only after the async fetch completes or fails, keeping updateLoadingState
and refreshControl.endRefreshing() aligned with the real request duration.

In `@NotificationService/NotificationService.swift`:
- Around line 24-37: Persist the notification write before handing off to
contentHandler in NotificationService.didReceive(_:withContentHandler:):
saveNotificationIfAvailable(userInfo:) currently fires an unstructured Task, so
make the save part of the same awaited flow before calling
contentHandler(bestAttemptContent). Update the NotificationService method flow
so the SwiftData insert completes (or is awaited) prior to delivery, and keep
serviceExtensionTimeWillExpire() only as the fallback path.

---

Nitpick comments:
In `@Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift`:
- Around line 11-26: The file name no longer matches the types defined inside
it. Rename the file to align with FetchNotificationHistoryUseCase and
DefaultFetchNotificationHistoryUseCase so it is easier to find and maintain the
use case implementation.

In `@Koin/Presentation/NewHome/Notification/NotificationViewController.swift`:
- Around line 185-188: The navigateToShop(shopId:) method is not using its
shopId parameter because this flow still pushes the generic shop list via
makeShopViewController(). Remove the unused shopId parameter from the method
signature and update any call sites accordingly so the method name and behavior
match the actual navigation.

In `@Koin/Presentation/NewHome/Notification/NotificationViewModel.swift`:
- Around line 96-117: The mutation helpers in NotificationViewModel are
swallowing persistence failures with try?, which can leave the optimistic UI out
of sync with SwiftData. Update deleteNotification(id:),
deleteAllNotifications(), markAsRead(id:), and markAllAsRead() to handle thrown
errors explicitly instead of discarding them, using the existing toast/error
pattern from loadNotifications or by triggering a refresh when a write fails.
Ensure the user gets feedback and the view state is reconciled after any failed
call to deleteNotificationHistoryUseCase or updateNotificationHistoryUseCase.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aef731ad-7ef3-47c1-aed1-2baeaf70ca6b

📥 Commits

Reviewing files that changed from the base of the PR and between 4a6d514 and b2c8b91.

📒 Files selected for processing (21)
  • Common/Core/AppPath.swift
  • Common/Core/NotificationHistoryError.swift
  • Common/NotificationHistory/NotificationHistoryService.swift
  • Common/NotificationHistory/NotificationRecord.swift
  • Koin/Data/Repository/DefaultNotificationHistoryRepository.swift
  • Koin/Domain/Model/NewHome/NotificationIconType.swift
  • Koin/Domain/Model/NewHome/NotificationItem.swift
  • Koin/Domain/Repository/NotificationHistoryRepository.swift
  • Koin/Domain/UseCase/NewHome/DeleteNotificationHistoryUseCase.swift
  • Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift
  • Koin/Domain/UseCase/NewHome/UpdateNotificationHistoryUseCase.swift
  • Koin/Presentation/NewHome/Category/CategoryHostingController.swift
  • Koin/Presentation/NewHome/Home/HomeHostingController.swift
  • Koin/Presentation/NewHome/Notification/NotificationViewController.swift
  • Koin/Presentation/NewHome/Notification/NotificationViewModel.swift
  • Koin/Presentation/NewHome/Notification/Subviews/NotificationPopUpViewController.swift
  • Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift
  • Koin/koin.entitlements
  • NotificationService/NotificationService.entitlements
  • NotificationService/NotificationService.swift
  • koin.xcodeproj/project.pbxproj
💤 Files with no reviewable changes (1)
  • Koin/Domain/Model/NewHome/NotificationIconType.swift

Comment on lines +10 to +14
struct NotificationHistoryError: Error {
let message: String

static let calendarDidFail = Self.init(message: "캘린더 오류 발생")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files ==\n'
git ls-files | rg 'Common/Core/NotificationHistoryError\.swift|NotificationViewModel|loadNotifications|NotificationHistoryError'

printf '\n== NotificationHistoryError.swift ==\n'
sed -n '1,120p' Common/Core/NotificationHistoryError.swift

printf '\n== Candidates using NotificationHistoryError or localizedDescription ==\n'
rg -n "NotificationHistoryError|localizedDescription" Common -g '!**/build/**' -g '!**/dist/**'

printf '\n== NotificationViewModel candidate file(s) ==\n'
fd -a 'NotificationViewModel' Common || true

Repository: BCSDLab/KOIN_iOS

Length of output: 1046


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' Common/Notification/NotificationViewModel.swift

Repository: BCSDLab/KOIN_iOS

Length of output: 245


🌐 Web query:

Swift Error localizedDescription non LocalizedError generic system message documentation

💡 Result:

In Swift, the localizedDescription property on the Error protocol is not a customization point [1][2]. Because it is defined as an extension on the Error protocol within the Foundation framework rather than a requirement of the protocol itself, it cannot be overridden directly [3][4]. When you access localizedDescription on an error, the default implementation performs a bridge to NSError [3][4]. If your error type does not conform to LocalizedError, this default implementation provides a generic system message (typically indicating that the error domain and code do not have a localized description) [2][3]. To provide a custom, localized message that localizedDescription will return, your error type must conform to the LocalizedError protocol [2][5]. 1. Conforming to LocalizedError: When you conform your type to LocalizedError, you implement the errorDescription property [6][7]. 2. How it works: The Foundation framework's bridging logic checks if an error conforms to LocalizedError [6][8]. If it does, and if errorDescription returns a string, that string is used to populate the NSLocalizedDescriptionKey in the underlying NSError's user info dictionary, which localizedDescription then returns [6][8][7]. 3. Default Behavior: If your type does not conform to LocalizedError, or if the errorDescription property returns nil, localizedDescription falls back to the generic system behavior provided by NSError [2]. Therefore, attempting to override localizedDescription directly in your error type will not work as intended for dynamic dispatch; you must instead implement the errorDescription requirement defined by the LocalizedError protocol [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "extension\s+Error|extension\s+LocalizedError|errorDescription|localizedDescription" Common -g '!**/build/**' -g '!**/dist/**'

Repository: BCSDLab/KOIN_iOS

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NotificationHistoryError usage ==\n'
rg -n "NotificationHistoryError|localizedDescription" . -g '!**/build/**' -g '!**/dist/**'

printf '\n== NotificationHistoryService.swift ==\n'
sed -n '110,160p' Common/NotificationHistory/NotificationHistoryService.swift

printf '\n== Notification view model files ==\n'
sed -n '1,140p' Koin/Presentation/NewHome/Notification/NotificationViewModel.swift
printf '\n---\n'
sed -n '1,140p' Koin/Presentation/CallVan/CallVanNotification/CallVanNotificationViewModel.swift

Repository: BCSDLab/KOIN_iOS

Length of output: 12862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NotificationHistoryError usage ==\n'
rg -n "NotificationHistoryError|localizedDescription" . -g '!**/build/**' -g '!**/dist/**'

printf '\n== NotificationHistoryService.swift ==\n'
sed -n '110,160p' Common/NotificationHistory/NotificationHistoryService.swift

printf '\n== Notification view model files ==\n'
sed -n '1,160p' Koin/Presentation/NewHome/Notification/NotificationViewModel.swift
printf '\n---\n'
sed -n '1,160p' Koin/Presentation/CallVan/CallVanNotification/CallVanNotificationViewModel.swift

Repository: BCSDLab/KOIN_iOS

Length of output: 12862


Adopt LocalizedError so the toast shows message.

NotificationViewModel.loadNotifications() surfaces this error with error.localizedDescription, but NotificationHistoryError only conforms to Error, so the toast falls back to a generic system message instead of "캘린더 오류 발생".

🩹 Proposed fix
-struct NotificationHistoryError: Error {
+struct NotificationHistoryError: LocalizedError {
     let message: String
     
     static let calendarDidFail = Self.init(message: "캘린더 오류 발생")
+
+    var errorDescription: String? { message }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
struct NotificationHistoryError: Error {
let message: String
static let calendarDidFail = Self.init(message: "캘린더 오류 발생")
}
struct NotificationHistoryError: LocalizedError {
let message: String
static let calendarDidFail = Self.init(message: "캘린더 오류 발생")
var errorDescription: String? { message }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/Core/NotificationHistoryError.swift` around lines 10 - 14,
NotificationHistoryError currently only conforms to Error, so
error.localizedDescription in NotificationViewModel.loadNotifications() won’t
surface the custom message and the toast falls back to a generic system string.
Update NotificationHistoryError to conform to LocalizedError and make its error
description return the existing message property so the UI shows the intended
text. Keep the change centered on NotificationHistoryError and verify the
calendarDidFail case still exposes "캘린더 오류 발생" through localizedDescription.

Comment on lines +34 to +48
func insert(record: NotificationRecord) async throws {
guard let container else {
throw SwiftDataError.loadIssueModelContainer
}

switch record.category {
case .shop, .dining, .keyword, .chat, .callvan, .callvanChat:
try? await MainActor.run { [weak container] in
container?.mainContext.insert(record)
try container?.mainContext.save()
}
default:
break
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

insert swallows persistence errors and silently drops unsupported categories.

try? around MainActor.run discards any error thrown by container?.mainContext.save(), so the async throws signature can never actually surface a real save failure to callers. Combined with the default: break branch, any category outside the whitelist also silently no-ops. Callers (e.g. NotificationService.didReceive persisting incoming pushes) have no way to detect a dropped/failed notification.

🩹 Proposed fix
         switch record.category {
         case .shop, .dining, .keyword, .chat, .callvan, .callvanChat:
-            try? await MainActor.run { [weak container] in
-                container?.mainContext.insert(record)
-                try container?.mainContext.save()
-            }
+            try await MainActor.run {
+                container.mainContext.insert(record)
+                try container.mainContext.save()
+            }
         default:
             break
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/NotificationHistory/NotificationHistoryService.swift` around lines 34
- 48, The NotificationHistoryService.insert(record:) implementation is
swallowing persistence failures because `try?` wraps the `MainActor.run` save
path, and unsupported categories are silently ignored in the `default` branch.
Update `insert(record:)` so save errors from `container.mainContext.save()` are
allowed to propagate to callers, and replace the silent no-op for unsupported
categories with an explicit failure path or validation using the existing
`record.category` switch. Keep the fix localized to `insert(record:)` and the
`NotificationRecord` category handling so `NotificationService.didReceive` can
detect dropped or failed inserts.

Comment on lines +117 to +125
func deleteAll() async throws {
guard let container else {
throw SwiftDataError.loadIssueModelContainer
}

try await MainActor.run {
try container.mainContext.delete(model: NotificationRecord.self)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

deleteAll() never persists the deletion — missing save().

Per SwiftData's documented behavior, batch deletion via delete(model:)/delete(model:where:) is only applied to the persistent store on the next save(). This method never calls save(), unlike every other mutating method in this file (insert, markAsRead, markAllAsRead, delete(messageId:)), so deleteAll() will silently no-op unless a save happens to occur elsewhere afterward.

🩹 Proposed fix
         try await MainActor.run {
             try container.mainContext.delete(model: NotificationRecord.self)
+            try container.mainContext.save()
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func deleteAll() async throws {
guard let container else {
throw SwiftDataError.loadIssueModelContainer
}
try await MainActor.run {
try container.mainContext.delete(model: NotificationRecord.self)
}
}
func deleteAll() async throws {
guard let container else {
throw SwiftDataError.loadIssueModelContainer
}
try await MainActor.run {
try container.mainContext.delete(model: NotificationRecord.self)
try container.mainContext.save()
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/NotificationHistory/NotificationHistoryService.swift` around lines 117
- 125, The deleteAll() method in NotificationHistoryService only performs
container.mainContext.delete(model:) and never persists the change, so add a
save on the same context after the deletion inside the MainActor.run block,
following the pattern used by insert, markAsRead, markAllAsRead, and
delete(messageId:). Ensure the save is awaited and any thrown error is
propagated so the batch deletion is actually committed to the persistent store.

Comment on lines +128 to +145
extension DefaultNotificationHistoryService {
private func deleteExpiredNotifications() async throws {
guard let container else {
throw SwiftDataError.loadIssueModelContainer
}

guard let expirationDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else {
throw NotificationHistoryError.calendarDidFail
}

try await MainActor.run {
try container.mainContext.delete(model: NotificationRecord.self, where: #Predicate { notification in
notification.createdAt < expirationDate
})
try container.mainContext.save()
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retention window is 7 days, but PR intent states 14 days.

The commit summary describes "deleting notification records older than 14 days when reading them," but this cleanup uses value: -7. If 14 days is the intended retention policy, this needs correcting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/NotificationHistory/NotificationHistoryService.swift` around lines 128
- 145, The cleanup window in
DefaultNotificationHistoryService.deleteExpiredNotifications() is using a 7-day
cutoff, which conflicts with the intended 14-day retention policy. Update the
date calculation in deleteExpiredNotifications() so the expirationDate is based
on 14 days ago, and keep the existing delete/save flow in the MainActor.run
block unchanged.

import Foundation

@Model
final class NotificationRecord: Sendable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File list ==\n'
git ls-files 'Common/NotificationHistory/*' 'Common/*Notification*' '*/NotificationService.swift' '*/NotificationHistoryService.swift'

printf '\n== Outline: NotificationRecord.swift ==\n'
ast-grep outline Common/NotificationHistory/NotificationRecord.swift --view expanded || true

printf '\n== NotificationRecord.swift ==\n'
cat -n Common/NotificationHistory/NotificationRecord.swift

printf '\n== NotificationService.swift matches ==\n'
rg -n "NotificationRecord|Task\s*\{|MainActor\.run|insert\(" Common/NotificationHistory NotificationService.swift Common -g '*.swift' || true

printf '\n== NotificationHistoryService.swift matches ==\n'
rg -n "NotificationRecord|Task\s*\{|MainActor\.run|insert\(" Common/NotificationHistory NotificationHistoryService.swift Common -g '*.swift' || true

Repository: BCSDLab/KOIN_iOS

Length of output: 11592


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Relevant Swift files ==\n'
fd -a 'Notification(Service|HistoryService|Record)\.swift$' . || true

printf '\n== Search for Sendable / `@Model` / unique ==\n'
rg -n "`@Model`|Sendable|`@Attribute`\\(\\.unique\\)|messageId|PersistentIdentifier" Common -g '*.swift' || true

Repository: BCSDLab/KOIN_iOS

Length of output: 1546


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NotificationHistoryService.swift (1-220) ==\n'
cat -n Common/NotificationHistory/NotificationHistoryService.swift

printf '\n== NotificationService.swift (1-220) ==\n'
cat -n NotificationService/NotificationService.swift

Repository: BCSDLab/KOIN_iOS

Length of output: 10492


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [
    Path('Common/NotificationHistory/NotificationHistoryService.swift'),
    Path('NotificationService/NotificationService.swift'),
]:
    print(f"\n== {path} ==")
    for i, line in enumerate(path.read_text().splitlines(), 1):
        if 1 <= i <= 220:
            print(f"{i:4d}: {line}")
PY

Repository: BCSDLab/KOIN_iOS

Length of output: 10278


Drop Sendable from NotificationRecord, and make messageId unique.

  • NotificationRecord is a mutable SwiftData @Model class, so Sendable only masks actor-isolation diagnostics when the instance crosses into Task {}/MainActor.run.
  • messageId is the lookup key for markAsRead and delete; without a uniqueness constraint, duplicate deliveries can create duplicate rows and inconsistent history.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/NotificationHistory/NotificationRecord.swift` at line 12, Remove
Sendable from NotificationRecord since it is a mutable SwiftData `@Model` class
and should not be treated as thread-safe across Task/MainActor boundaries. Also
update NotificationRecord so messageId is declared as a unique lookup key in the
model definition, ensuring markAsRead and delete operate on a single row and
duplicate deliveries cannot create duplicate records.

Comment on lines +41 to +43
func makrAllAsRead() async throws {
try await service.markAllAsRead()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Typo: makrAllAsReadmarkAllAsRead.

Naming typo in the public repository API. Since this must match the NotificationHistoryRepository protocol signature to compile, the same typo likely exists there too — worth fixing in both places while it's still low-diff.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Data/Repository/DefaultNotificationHistoryRepository.swift` around lines
41 - 43, The public repository API has a naming typo in
DefaultNotificationHistoryRepository: rename makrAllAsRead to markAllAsRead so
it matches the NotificationHistoryRepository protocol and compiles cleanly.
Update the same method name anywhere it is declared or referenced, including the
protocol signature and any conforming implementations, so the async
mark-all-read flow remains consistent.

Comment on lines +40 to +74

extension NotificationItem {

init?(from record: NotificationRecord) {
guard let icon = NotificationItem.icon(for: record.category) else {
return nil
}
self.id = record.messageId
self.isRead = record.isRead
self.icon = icon
self.appPath = record.category
self.uri = record.schemeUri
self.title = record.title
self.content = record.body
self.dateText = NotificationItem.dateText(for: record.createdAt)
}

static func icon(for appPath: AppPath) -> ImageAsset? {
switch appPath {
case .shop:
return ImageAsset.notificationShop
case .dining:
return ImageAsset.notificationDining
case .keyword:
return ImageAsset.notificationLostItem
case .chat:
return ImageAsset.notificationChat
case .callvan:
return ImageAsset.notificationCallVan
case .callvanChat:
return ImageAsset.notificationChat
default:
return nil
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Notifications for .home, .login, .club, .timeTable categories are silently dropped from history.

icon(for:)'s default: return nil causes init?(from:) to return nil, and DefaultNotificationHistoryRepository.fetchAll() uses compactMap, so any persisted NotificationRecord with one of the unhandled AppPath cases (.home, .login, .club, .timeTable) is stored but never shown to the user, with no error surfaced. Since these are valid AppPath cases that NotificationService.saveNotificationIfAvailable will happily persist, this isn't just a future-proofing concern.

Consider either providing a fallback icon for the remaining cases, or making the switch exhaustive (dropping default) so adding a new AppPath case forces this mapping to be revisited at compile time.

♻️ Example fix
     static func icon(for appPath: AppPath) -> ImageAsset? {
         switch appPath {
         case .shop:
             return ImageAsset.notificationShop
         case .dining:
             return ImageAsset.notificationDining
         case .keyword:
             return ImageAsset.notificationLostItem
         case .chat:
             return ImageAsset.notificationChat
         case .callvan:
             return ImageAsset.notificationCallVan
         case .callvanChat:
             return ImageAsset.notificationChat
-        default:
-            return nil
+        case .home, .login, .club, .timeTable:
+            return ImageAsset.notificationDefault // or an appropriate fallback
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extension NotificationItem {
init?(from record: NotificationRecord) {
guard let icon = NotificationItem.icon(for: record.category) else {
return nil
}
self.id = record.messageId
self.isRead = record.isRead
self.icon = icon
self.appPath = record.category
self.uri = record.schemeUri
self.title = record.title
self.content = record.body
self.dateText = NotificationItem.dateText(for: record.createdAt)
}
static func icon(for appPath: AppPath) -> ImageAsset? {
switch appPath {
case .shop:
return ImageAsset.notificationShop
case .dining:
return ImageAsset.notificationDining
case .keyword:
return ImageAsset.notificationLostItem
case .chat:
return ImageAsset.notificationChat
case .callvan:
return ImageAsset.notificationCallVan
case .callvanChat:
return ImageAsset.notificationChat
default:
return nil
}
}
extension NotificationItem {
init?(from record: NotificationRecord) {
guard let icon = NotificationItem.icon(for: record.category) else {
return nil
}
self.id = record.messageId
self.isRead = record.isRead
self.icon = icon
self.appPath = record.category
self.uri = record.schemeUri
self.title = record.title
self.content = record.body
self.dateText = NotificationItem.dateText(for: record.createdAt)
}
static func icon(for appPath: AppPath) -> ImageAsset? {
switch appPath {
case .shop:
return ImageAsset.notificationShop
case .dining:
return ImageAsset.notificationDining
case .keyword:
return ImageAsset.notificationLostItem
case .chat:
return ImageAsset.notificationChat
case .callvan:
return ImageAsset.notificationCallVan
case .callvanChat:
return ImageAsset.notificationChat
case .home, .login, .club, .timeTable:
return ImageAsset.notificationDefault // or an appropriate fallback
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Domain/Model/NewHome/NotificationItem.swift` around lines 40 - 74, The
notification mapping in NotificationItem.icon(for:) is dropping valid AppPath
cases because the default branch returns nil, which makes init?(from:) fail and
causes DefaultNotificationHistoryRepository.fetchAll() to skip persisted records
via compactMap. Update the icon mapping so .home, .login, .club, and .timeTable
are either assigned appropriate fallback icons or handled explicitly, and remove
the catch-all default if possible so future AppPath additions are forced to be
mapped at compile time.

func deleteAll() async throws
func delete(id: String) async throws
func markAsRead(id: String) async throws
func makrAllAsRead() async throws

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Typo: makrAllAsReadmarkAllAsRead.

This new protocol method name is misspelled. It's consistently used in DefaultNotificationHistoryRepository and DefaultUpdateNotificationHistoryUseCase, so fixing it now (before this API is more widely adopted) is cheap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Domain/Repository/NotificationHistoryRepository.swift` at line 15,
Rename the misspelled repository API from makrAllAsRead to markAllAsRead in
NotificationHistoryRepository and update every matching implementation/call
site, including DefaultNotificationHistoryRepository and
DefaultUpdateNotificationHistoryUseCase, so the protocol and its uses all share
the corrected method name.

Comment on lines 80 to 94
private func loadNotifications() {
outputSubject.send(.updateLoading(true))
fetchNotificationListUseCase.execute().sink(
receiveCompletion: { [weak self] completion in
self?.outputSubject.send(.updateLoading(false))
if case .failure(let error) = completion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] notifications in
guard let self else { return }
self.outputSubject.send(.updateNotifications(notifications))
self.outputSubject.send(.updateLoading(false))
}
)
.store(in: &subscriptions)
defer {
outputSubject.send(.updateLoading(false))
}

Task {
do {
let notifications = try await fetchNotificationHistoryUseCase.execute()
outputSubject.send(.updateNotifications(notifications))
} catch {
outputSubject.send(.showToast(error.localizedDescription))
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

defer fires before the async fetch completes.

loadNotifications() is synchronous, so the defer runs when the function returns — which is immediately after the Task is scheduled, not after fetchNotificationHistoryUseCase.execute() resolves. As a result updateLoading(false) is emitted right away, the loading indicator never reflects the real fetch duration, and (via updateLoadingState) refreshControl.endRefreshing() ends before data arrives. Move the defer inside the Task.

🐛 Proposed fix
     private func loadNotifications() {
         outputSubject.send(.updateLoading(true))
-        defer {
-            outputSubject.send(.updateLoading(false))
-        }
-        
         Task {
+            defer {
+                outputSubject.send(.updateLoading(false))
+            }
             do {
                 let notifications = try await fetchNotificationHistoryUseCase.execute()
                 outputSubject.send(.updateNotifications(notifications))
             } catch {
                 outputSubject.send(.showToast(error.localizedDescription))
             }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func loadNotifications() {
outputSubject.send(.updateLoading(true))
fetchNotificationListUseCase.execute().sink(
receiveCompletion: { [weak self] completion in
self?.outputSubject.send(.updateLoading(false))
if case .failure(let error) = completion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] notifications in
guard let self else { return }
self.outputSubject.send(.updateNotifications(notifications))
self.outputSubject.send(.updateLoading(false))
}
)
.store(in: &subscriptions)
defer {
outputSubject.send(.updateLoading(false))
}
Task {
do {
let notifications = try await fetchNotificationHistoryUseCase.execute()
outputSubject.send(.updateNotifications(notifications))
} catch {
outputSubject.send(.showToast(error.localizedDescription))
}
}
}
private func loadNotifications() {
outputSubject.send(.updateLoading(true))
Task {
defer {
outputSubject.send(.updateLoading(false))
}
do {
let notifications = try await fetchNotificationHistoryUseCase.execute()
outputSubject.send(.updateNotifications(notifications))
} catch {
outputSubject.send(.showToast(error.localizedDescription))
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Koin/Presentation/NewHome/Notification/NotificationViewModel.swift` around
lines 80 - 94, The loading reset in NotificationViewModel.loadNotifications() is
happening too early because the defer runs when the synchronous method returns,
not after fetchNotificationHistoryUseCase.execute() finishes. Move the loading
false cleanup into the Task scope in loadNotifications(), so
updateLoading(false) is sent only after the async fetch completes or fails,
keeping updateLoadingState and refreshControl.endRefreshing() aligned with the
real request duration.

Comment on lines +24 to +37

// MARK: - SwiftData에 Notification 데이터를 기록한다.
saveNotificationIfAvailable(userInfo: bestAttemptContent.userInfo)

// MARK: - 푸시알림이 이미지를 포함하는 경우 처리
if let fcmOptionsUserInfo = bestAttemptContent.userInfo["fcm_options"] as? [String: Any],
let imageURLString = fcmOptionsUserInfo["image"] as? String,
let imageURL = URL(string: imageURLString) {

// 이미지 다운로드
if let imageData = try? Data(contentsOf: imageURL) {
// UNNotificationAttachment 설정
if let attachment = UNNotificationAttachment.saveImageToDisk(identifier: "certificationImage.jpg", data: imageData, options: nil) {
bestAttemptContent.attachments = [attachment]
contentHandler(bestAttemptContent)
} else {
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
let imageURL = URL(string: imageURLString),
let imageData = try? Data(contentsOf: imageURL),
let attachment = UNNotificationAttachment.saveImageToDisk(identifier: "certificationImage.jpg", data: imageData, options: nil) {
bestAttemptContent.attachments = [attachment]
}

contentHandler(bestAttemptContent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect the relevant ranges.
git ls-files | rg '^NotificationService/NotificationService\.swift$'
wc -l NotificationService/NotificationService.swift
sed -n '1,220p' NotificationService/NotificationService.swift

Repository: BCSDLab/KOIN_iOS

Length of output: 4256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the persistence implementation and any extension-time handling.
rg -n "DefaultNotificationHistoryService|insert\\(record:|serviceExtensionTimeWillExpire|Task \\{" NotificationService -g '!**/xcuserdata/**'

# Inspect the persistence service files if present.
for f in $(git ls-files | rg 'DefaultNotificationHistoryService|NotificationHistoryService|NotificationRecord'); do
  echo "===== $f ====="
  wc -l "$f"
  sed -n '1,240p' "$f"
done

Repository: BCSDLab/KOIN_iOS

Length of output: 5912


Persist the notification write before contentHandler. saveNotificationIfAvailable(userInfo:) launches an unstructured Task, and didReceive calls contentHandler(bestAttemptContent) immediately after. If the extension is suspended or terminated after that handoff, the SwiftData insert can be cancelled and the notification history entry is lost. Run the save in the same awaited flow, and keep serviceExtensionTimeWillExpire() as a best-attempt fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NotificationService/NotificationService.swift` around lines 24 - 37, Persist
the notification write before handing off to contentHandler in
NotificationService.didReceive(_:withContentHandler:):
saveNotificationIfAvailable(userInfo:) currently fires an unstructured Task, so
make the save part of the same awaited flow before calling
contentHandler(bestAttemptContent). Update the NotificationService method flow
so the SwiftData insert completes (or is awaited) prior to delivery, and keep
serviceExtensionTimeWillExpire() only as the fallback path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FEAT 기능 개발 및 api 연동

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant