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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion AkFit/AkFit/Auth/AuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ final class AuthManager {

private let guestStore: GuestDataStore

/// Cold-start safety timeout (see `init`). Cancelled as soon as the auth
/// observer receives its first event so a slow `fetchUserData` can never
/// flash `AuthView` at an already signed-in user mid-resolution.
private var loadingTimeoutTask: Task<Void, Never>?

// MARK: - Computed: profile and goal (unified for both paths)

/// The user's profile. Sourced from `GuestDataStore` when in guest mode;
Expand Down Expand Up @@ -113,8 +118,11 @@ final class AuthManager {
// (SDK issue, network failure at cold start), clear `isLoading` after
// 10 seconds so the user isn't stuck on a blank screen forever.
// The auth observer normally clears `isLoading` in < 1 second.
Task {
// Cancelled by `handle(event:session:)` so the timeout can't expose
// an interactive `AuthView` while a session is still being resolved.
loadingTimeoutTask = Task {
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
if isLoading { isLoading = false }
}
}
Expand All @@ -141,6 +149,11 @@ final class AuthManager {
}

private func handle(event: AuthChangeEvent, session: Session?) async {
// An auth event is being processed — the cold-start timeout must not
// clear `isLoading` mid-resolution (that would show an interactive
// `AuthView`/guest entry point while a session fetch is in flight).
loadingTimeoutTask?.cancel()

switch event {

case .initialSession, .signedIn:
Expand All @@ -150,6 +163,14 @@ final class AuthManager {
guestStore.clearAll()
}
let result = await fetchUserData(userId: session.user.id)
// Re-check after the await: the user may have tapped
// "Continue as Guest" while fetchUserData was in flight.
// Without this, the app lands in `.authenticated` while
// `guestStore.isActive` stays true — and every store would
// silently write to UserDefaults instead of Supabase.
if guestStore.isActive {
guestStore.clearAll()
}
self._serverProfile = result.profile
self._serverGoal = result.goal
self.dataFetchFailed = result.fetchFailed
Expand Down Expand Up @@ -227,6 +248,7 @@ final class AuthManager {
return (value, false)
} catch {
if Self.isNotFound(error) { return (nil, false) }
captureFetchFailure(error, table: "profiles")
return (nil, true)
}
}
Expand All @@ -245,10 +267,35 @@ final class AuthManager {
return (value, false)
} catch {
if Self.isNotFound(error) { return (nil, false) }
captureFetchFailure(error, table: "goals")
return (nil, true)
}
}

/// Reports a profile/goal fetch failure to Sentry (these route the user to
/// `DataFetchErrorView`). Plain connectivity errors are skipped — they are
/// expected on flaky networks and would only add noise.
private func captureFetchFailure(_ error: Error, table: String) {
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut,
.cannotFindHost, .cannotConnectToHost, .dataNotAllowed:
return
default:
break
}
}
SentryMonitoring.captureNonFatal(
error,
operation: "user_data_fetch",
tags: [
"table": table,
"classification": SaveErrorClassification.classification(of: error),
"postgrest_code": SaveErrorClassification.postgrestCode(of: error),
]
)
}

// MARK: - Retry after fetch failure (authenticated path)

func retryFetchUserData() async {
Expand Down Expand Up @@ -407,6 +454,11 @@ final class AuthManager {
)
} catch {
debugDeleteAccount("edge function invocation failed: \(describeDeleteAccountError(error))")
SentryMonitoring.captureNonFatal(
error,
operation: "delete_account",
tags: ["stage": "edge_function_invoke"]
)
throw DeleteAccountError.serverError
}

Expand Down
116 changes: 25 additions & 91 deletions AkFit/AkFit/Views/Onboarding/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -734,11 +734,24 @@ private struct ResultsStepView: View {
"save() failed step=\(report.step.rawValue, privacy: .public) table=\(report.step.table, privacy: .public) action=\(report.step.action, privacy: .public) session_validated=\(report.sessionValidated, privacy: .public) classification=\(report.classification, privacy: .public) postgrest_code=\(report.postgrestCode, privacy: .public) auth_code=\(report.authCode, privacy: .public)"
)
debugOnboardingSave("save failed \(report.debugSummary)")
if shouldShowSessionExpiredMessage(for: error) {
errorMessage = "Session expired. Please sign out and sign back in."
} else {
errorMessage = "Couldn't save your targets. Please try again."
}
// Non-fatal telemetry: codes and step names only, no PII.
// Without this, deterministic failures (e.g. the 2026-06
// lean_bulk check violation) are invisible in production.
SentryMonitoring.captureNonFatal(
error,
operation: "onboarding_save",
tags: [
"step": report.step.rawValue,
"table": report.step.table,
"classification": report.classification,
"postgrest_code": report.postgrestCode,
"auth_code": report.authCode,
]
)
errorMessage = SaveErrorClassification.userMessage(
for: error,
action: "save your targets"
)
}
}
}
Expand All @@ -749,101 +762,22 @@ private struct ResultsStepView: View {
#endif
}

/// Builds the structured log/telemetry report for a failed save step.
/// Classification logic lives in `SaveErrorClassification` (shared with
/// `EditGoalView` / `EditProfileView` and unit-tested).
private func describeOnboardingSaveError(
_ error: Error,
step: SaveStep,
sessionValidated: Bool
) -> SaveErrorReport {
let defaultCode = "none"

if let authError = error as? AuthError {
return SaveErrorReport(
step: step,
sessionValidated: sessionValidated,
classification: classifyAuthError(authError),
postgrestCode: defaultCode,
authCode: authErrorCode(authError)
)
}

if let postgrestError = error as? PostgrestError {
return SaveErrorReport(
step: step,
sessionValidated: sessionValidated,
classification: classifyPostgrestError(postgrestError),
postgrestCode: postgrestError.code ?? defaultCode,
authCode: defaultCode
)
}

return SaveErrorReport(
SaveErrorReport(
step: step,
sessionValidated: sessionValidated,
classification: "unexpected_error",
postgrestCode: defaultCode,
authCode: defaultCode
classification: SaveErrorClassification.classification(of: error),
postgrestCode: SaveErrorClassification.postgrestCode(of: error),
authCode: SaveErrorClassification.authCode(of: error)
)
}

private func classifyPostgrestError(_ error: PostgrestError) -> String {
switch error.code {
case "42501":
return "postgrest_permission_denied"
case "23502":
return "postgrest_not_null_violation"
case "23503":
return "postgrest_foreign_key_violation"
case "23505":
return "postgrest_unique_violation"
case "23514":
return "postgrest_check_violation"
case "PGRST116":
return "postgrest_no_rows_returned"
case "PGRST301":
return "postgrest_jwt_invalid"
default:
return "postgrest_error"
}
}

private func classifyAuthError(_ error: AuthError) -> String {
switch error {
case .sessionMissing:
return "auth_session_missing"
case .jwtVerificationFailed:
return "auth_jwt_verification_failed"
case .api:
return "auth_api_error"
default:
return "auth_error"
}
}

private func authErrorCode(_ error: AuthError) -> String {
switch error {
case let .api(_, errorCode, _, underlyingResponse):
return "\(underlyingResponse.statusCode):\(errorCode.rawValue)"
case .sessionMissing:
return "session_missing"
case .jwtVerificationFailed:
return "jwt_verification_failed"
default:
return "auth_error"
}
}

private func shouldShowSessionExpiredMessage(for error: Error) -> Bool {
if error is AuthError { return true }

guard let postgrestError = error as? PostgrestError else {
return false
}

let message = postgrestError.message.lowercased()
return postgrestError.code == "PGRST301"
|| message.contains("jwt")
|| message.contains("auth")
}
}

// MARK: - SelectionCard
Expand Down
29 changes: 27 additions & 2 deletions AkFit/AkFit/Views/Search/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ struct SearchView: View {
/// Guards against rapid double-tap on swipe-to-log (quick-log) actions.
/// Set `true` before the insert call; cleared after it completes.
@State private var isQuickLogging = false
/// Shown when a swipe quick-log insert fails. Mirrors DashboardView's
/// `showDeleteError` pattern — previously the gesture failed silently and
/// users believed the food was logged.
@State private var showQuickLogError = false
/// Food names and brand names from the database, used as the type-ahead
/// suggestion pool. Fetched once on first appear. Guaranteed searchable.
@State private var typeAheadTerms: [String] = []
Expand Down Expand Up @@ -129,6 +133,11 @@ struct SearchView: View {
.accessibilityHidden(!showSuggestionPanel)
}
.navigationTitle("Search")
.alert("Couldn't log food", isPresented: $showQuickLogError) {
Button("OK", role: .cancel) {}
} message: {
Text("Please check your connection and try again.")
}
.searchable(
text: $query,
isPresented: $isSearchPresented,
Expand Down Expand Up @@ -569,7 +578,15 @@ struct SearchView: View {
notifications.cancelTodayReminder()
finishSuccessfulLog(backfilling: backfilling)
} catch {
// Preserve the existing silent quick-log failure behavior.
SentryMonitoring.captureNonFatal(
error,
operation: "quick_log",
tags: [
"classification": SaveErrorClassification.classification(of: error),
"postgrest_code": SaveErrorClassification.postgrestCode(of: error),
]
)
showQuickLogError = true
}
}
}
Expand Down Expand Up @@ -603,7 +620,15 @@ struct SearchView: View {
notifications.cancelTodayReminder()
finishSuccessfulLog(backfilling: backfilling)
} catch {
// Preserve the existing silent quick-log failure behavior.
SentryMonitoring.captureNonFatal(
error,
operation: "quick_log",
tags: [
"classification": SaveErrorClassification.classification(of: error),
"postgrest_code": SaveErrorClassification.postgrestCode(of: error),
]
)
showQuickLogError = true
}
}
}
Expand Down
18 changes: 13 additions & 5 deletions AkFit/AkFit/Views/Settings/EditGoalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,19 @@ struct EditGoalView: View {
authManager.updateProfile(updatedProfile)
dismiss()
} catch {
if error is AuthError {
saveError = "Session expired. Please sign out and sign back in."
} else {
saveError = "Couldn't save changes. Please try again."
}
SentryMonitoring.captureNonFatal(
error,
operation: "edit_goal_save",
tags: [
"classification": SaveErrorClassification.classification(of: error),
"postgrest_code": SaveErrorClassification.postgrestCode(of: error),
"auth_code": SaveErrorClassification.authCode(of: error),
]
)
saveError = SaveErrorClassification.userMessage(
for: error,
action: "save changes"
)
}
}
}
Expand Down
18 changes: 13 additions & 5 deletions AkFit/AkFit/Views/Settings/EditProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,19 @@ struct EditProfileView: View {
authManager.updateGoal(updatedGoal)
dismiss()
} catch {
if error is AuthError {
saveError = "Session expired. Please sign out and sign back in."
} else {
saveError = "Couldn't save changes. Please try again."
}
SentryMonitoring.captureNonFatal(
error,
operation: "edit_profile_save",
tags: [
"classification": SaveErrorClassification.classification(of: error),
"postgrest_code": SaveErrorClassification.postgrestCode(of: error),
"auth_code": SaveErrorClassification.authCode(of: error),
]
)
saveError = SaveErrorClassification.userMessage(
for: error,
action: "save changes"
)
}
}
}
Expand Down
Loading
Loading