diff --git a/AkFit/AkFit/Auth/AuthManager.swift b/AkFit/AkFit/Auth/AuthManager.swift index a7a12bc..ea69c50 100644 --- a/AkFit/AkFit/Auth/AuthManager.swift +++ b/AkFit/AkFit/Auth/AuthManager.swift @@ -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? + // MARK: - Computed: profile and goal (unified for both paths) /// The user's profile. Sourced from `GuestDataStore` when in guest mode; @@ -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 } } } @@ -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: @@ -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 @@ -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) } } @@ -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 { @@ -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 } diff --git a/AkFit/AkFit/Views/Onboarding/OnboardingView.swift b/AkFit/AkFit/Views/Onboarding/OnboardingView.swift index 73d5330..9b2d45f 100644 --- a/AkFit/AkFit/Views/Onboarding/OnboardingView.swift +++ b/AkFit/AkFit/Views/Onboarding/OnboardingView.swift @@ -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" + ) } } } @@ -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 diff --git a/AkFit/AkFit/Views/Search/SearchView.swift b/AkFit/AkFit/Views/Search/SearchView.swift index 7632aae..51eb0d4 100644 --- a/AkFit/AkFit/Views/Search/SearchView.swift +++ b/AkFit/AkFit/Views/Search/SearchView.swift @@ -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] = [] @@ -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, @@ -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 } } } @@ -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 } } } diff --git a/AkFit/AkFit/Views/Settings/EditGoalView.swift b/AkFit/AkFit/Views/Settings/EditGoalView.swift index 0d31610..7355f8a 100644 --- a/AkFit/AkFit/Views/Settings/EditGoalView.swift +++ b/AkFit/AkFit/Views/Settings/EditGoalView.swift @@ -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" + ) } } } diff --git a/AkFit/AkFit/Views/Settings/EditProfileView.swift b/AkFit/AkFit/Views/Settings/EditProfileView.swift index a83787d..91d940d 100644 --- a/AkFit/AkFit/Views/Settings/EditProfileView.swift +++ b/AkFit/AkFit/Views/Settings/EditProfileView.swift @@ -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" + ) } } } diff --git a/AkFit/AkFitTests/SaveErrorClassificationTests.swift b/AkFit/AkFitTests/SaveErrorClassificationTests.swift new file mode 100644 index 0000000..e0b558c --- /dev/null +++ b/AkFit/AkFitTests/SaveErrorClassificationTests.swift @@ -0,0 +1,162 @@ +import Testing +import Foundation +import Supabase +@testable import AkFit + +// MARK: - SaveErrorClassification tests + +/// Covers the shared save-error classification used by the onboarding results +/// step, EditGoalView, and EditProfileView. +/// +/// Regression context: the 2026-06 incident where the live `goals` table +/// rejected `lean_bulk` (SQLSTATE 23514) surfaced as "Please try again." — +/// a retry that could never succeed. Non-retryable server rejects must now +/// produce the "server problem / update the app" message. +struct SaveErrorClassificationTests { + + // MARK: - Non-retryable PostgREST codes + + @Test(arguments: ["23502", "23503", "23514", "42501", "PGRST204"]) + func nonRetryableCodes_classifyAsNonRetryable(code: String) { + let error = PostgrestError(code: code, message: "rejected") + #expect(SaveErrorClassification.kind(of: error) == .nonRetryable) + } + + @Test func checkViolation_messageDoesNotSayTryAgain() { + let error = PostgrestError(code: "23514", message: "check constraint violated") + let message = SaveErrorClassification.userMessage(for: error, action: "save your targets") + #expect(!message.lowercased().contains("try again")) + #expect(message.contains("server problem")) + } + + // MARK: - Session-expired classification + + @Test func sessionMissing_classifiesAsSessionExpired() { + let error = AuthError.sessionMissing + #expect(SaveErrorClassification.kind(of: error) == .sessionExpired) + #expect( + SaveErrorClassification.userMessage(for: error, action: "save changes") + == "Session expired. Please sign out and sign back in." + ) + } + + @Test func postgrestJWTInvalid_classifiesAsSessionExpired() { + let error = PostgrestError(code: "PGRST301", message: "JWT expired") + #expect(SaveErrorClassification.kind(of: error) == .sessionExpired) + } + + @Test func postgrestJWTMessage_withoutCode_classifiesAsSessionExpired() { + let error = PostgrestError(code: nil, message: "invalid JWT signature") + #expect(SaveErrorClassification.kind(of: error) == .sessionExpired) + } + + @Test(arguments: [ErrorCode.invalidJWT, .badJWT]) + func authAPIJWTErrorCodes_classifyAsSessionExpired(errorCode: ErrorCode) throws { + let error = try Self.authAPIError(errorCode: errorCode) + #expect(SaveErrorClassification.kind(of: error) == .sessionExpired) + } + + // MARK: - Retryable defaults + + @Test func plainNetworkError_classifiesAsRetryable() { + let error = URLError(.notConnectedToInternet) + #expect(SaveErrorClassification.kind(of: error) == .retryable) + let message = SaveErrorClassification.userMessage(for: error, action: "save your targets") + #expect(message.contains("try again")) + } + + @Test func uniqueViolation_staysRetryable() { + // 23505 is intentionally NOT in the non-retryable set: a unique + // violation usually means the row already exists, and a retry path + // that re-reads state can succeed. + let error = PostgrestError(code: "23505", message: "duplicate key") + #expect(SaveErrorClassification.kind(of: error) == .retryable) + } + + // MARK: - Structured log fields + + @Test func classification_mapsKnownPostgrestCodes() { + #expect( + SaveErrorClassification.classification( + of: PostgrestError(code: "23514", message: "x") + ) == "postgrest_check_violation" + ) + #expect( + SaveErrorClassification.classification( + of: PostgrestError(code: "42501", message: "x") + ) == "postgrest_permission_denied" + ) + #expect( + SaveErrorClassification.classification(of: URLError(.timedOut)) + == "network_error" + ) + #expect( + SaveErrorClassification.classification( + of: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "invalid response") + ) + ) == "decode_error" + ) + } + + @Test func codeAccessors_defaultToNone() { + let urlError = URLError(.timedOut) + #expect(SaveErrorClassification.postgrestCode(of: urlError) == "none") + #expect(SaveErrorClassification.authCode(of: urlError) == "none") + #expect(SaveErrorClassification.authCode(of: AuthError.sessionMissing) == "session_missing") + } + + private static func authAPIError(errorCode: ErrorCode) throws -> AuthError { + let url = try #require(URL(string: "https://example.com/auth/v1/user")) + let response = try #require( + HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: nil + ) + ) + return AuthError.api( + message: "JWT is invalid", + errorCode: errorCode, + underlyingData: Data(), + underlyingResponse: response + ) + } +} + +// MARK: - GoalType ↔ database contract + +/// Tripwire for the exact bug class behind the 2026-06 onboarding incident: +/// the app's `GoalType` raw values MUST match the `goals_goal_type_check` +/// constraint in the live database. +/// +/// The allowed set lives in: +/// supabase/migrations/20260611054748_fix_goals_goal_type_check.sql +/// (verification: docs/debugging-supabase-errors.md → drift check) +/// +/// If this test fails, you renamed or added a `GoalType` case — ship the +/// matching constraint migration FIRST, then update this list. +struct GoalTypeDatabaseContractTests { + + private static let databaseAllowedGoalTypes: Set = [ + "fat_loss", "maintenance", "lean_bulk", + ] + + @Test func goalTypeRawValues_matchDatabaseCheckConstraint() { + let appValues = Set(UserGoal.GoalType.allCases.map(\.rawValue)) + #expect(appValues == Self.databaseAllowedGoalTypes) + } + + @Test func paceRawValues_matchDatabaseCheckConstraint() { + // goals.target_pace check: ('slow','moderate','fast') — see + // 20260401000014_reconcile_schema.sql. Pace is not CaseIterable, so + // enumerate explicitly. + let appValues: Set = [ + UserGoal.Pace.slow.rawValue, + UserGoal.Pace.moderate.rawValue, + UserGoal.Pace.fast.rawValue, + ] + #expect(appValues == ["slow", "moderate", "fast"]) + } +} diff --git a/AkFit/Services/SentryMonitoring.swift b/AkFit/Services/SentryMonitoring.swift index 1e95d02..9e2d1df 100644 --- a/AkFit/Services/SentryMonitoring.swift +++ b/AkFit/Services/SentryMonitoring.swift @@ -27,4 +27,28 @@ enum SentryMonitoring { } #endif } + + /// Captures a handled (non-fatal) error with non-PII context tags. + /// + /// Added after the 2026-06 onboarding incident: the lean_bulk check + /// violation failed onboarding saves for ~19% of signups for two months + /// with zero Sentry events, because every catch path logged to os.log + /// only. Critical catch sites now report here so production failures are + /// visible with release attribution. + /// + /// **Privacy rule:** tag values must be enum raw values, error codes, or + /// fixed strings — never user IDs, emails, tokens, or free-form user data. + static func captureNonFatal( + _ error: Error, + operation: String, + tags: [String: String] = [:] + ) { + guard !AppConfig.sentryDSN.isEmpty else { return } + SentrySDK.capture(error: error) { scope in + scope.setTag(value: operation, key: "akfit.operation") + for (key, value) in tags { + scope.setTag(value: value, key: "akfit.\(key)") + } + } + } } diff --git a/AkFit/Services/Supabase/SaveErrorClassification.swift b/AkFit/Services/Supabase/SaveErrorClassification.swift new file mode 100644 index 0000000..c7115e2 --- /dev/null +++ b/AkFit/Services/Supabase/SaveErrorClassification.swift @@ -0,0 +1,142 @@ +import Foundation +import Supabase + +/// Shared classification of Supabase save errors for user-facing messaging +/// and structured logging. +/// +/// Extracted from `OnboardingView` so the onboarding results step, +/// `EditGoalView`, and `EditProfileView` agree on one rule set — and so the +/// classification is unit-testable. +/// +/// ## Why "non-retryable" matters +/// The 2026-06 onboarding incident (live `goals_goal_type_check` rejecting +/// `lean_bulk`, SQLSTATE 23514) showed users a "Please try again." message for +/// an error that could never succeed on retry. Check violations, RLS denials, +/// and not-null/foreign-key violations are deterministic server-side rejects: +/// the user needs an app update or support, not a retry. +nonisolated enum SaveErrorClassification { + + // MARK: - Outcome buckets + + nonisolated enum Kind { + /// The session is missing/expired/invalid — re-authentication fixes it. + /// (supabase-swift auto-signs-out on fatal refresh failures, so the + /// user normally lands on the sign-in screen moments later.) + case sessionExpired + /// Deterministic server-side reject (check violation, RLS denial, + /// not-null/FK violation, unknown column). Retrying cannot succeed. + case nonRetryable + /// Anything else — network blips, timeouts, transient 5xx. Retry is + /// the right advice. + case retryable + } + + /// PostgREST error codes that can never succeed on retry with the same + /// payload. See docs/debugging-supabase-errors.md for the full map. + private static let nonRetryablePostgrestCodes: Set = [ + "23502", // not_null_violation + "23503", // foreign_key_violation + "23514", // check_violation (the 2026-06 lean_bulk incident) + "42501", // insufficient_privilege (RLS denial) + "PGRST204", // column not found in schema cache + ] + + static func kind(of error: Error) -> Kind { + if let authError = error as? AuthError { + switch authError { + case .sessionMissing, .jwtVerificationFailed: + return .sessionExpired + case let .api(_, errorCode, _, _): + // Invalid/expired JWT api errors are session problems; other + // GoTrue api errors (5xx, rate limits) are transient. + return (errorCode == .invalidJWT || errorCode == .badJWT) + ? .sessionExpired + : .retryable + default: + return .retryable + } + } + + if let postgrestError = error as? PostgrestError { + if postgrestError.code == "PGRST301" { return .sessionExpired } + if let code = postgrestError.code, + nonRetryablePostgrestCodes.contains(code) { + return .nonRetryable + } + let message = postgrestError.message.lowercased() + if message.contains("jwt") { return .sessionExpired } + return .retryable + } + + return .retryable + } + + // MARK: - User-facing copy + + /// One consistent message per outcome bucket. + /// + /// - Parameter action: what failed, in sentence-fragment form — + /// e.g. `"save your targets"` (onboarding) or `"save changes"` (edits). + static func userMessage(for error: Error, action: String) -> String { + switch kind(of: error) { + case .sessionExpired: + return "Session expired. Please sign out and sign back in." + case .nonRetryable: + return "Couldn't \(action) due to a server problem. Please update to the latest version of AkFit, or contact support if this continues." + case .retryable: + return "Couldn't \(action). Please check your connection and try again." + } + } + + // MARK: - Structured-log fields + + /// Stable classification string for os.log / Sentry tags. + static func classification(of error: Error) -> String { + if let authError = error as? AuthError { + switch authError { + case .sessionMissing: return "auth_session_missing" + case .jwtVerificationFailed: return "auth_jwt_verification_failed" + case .api: return "auth_api_error" + default: return "auth_error" + } + } + + if let postgrestError = error as? PostgrestError { + switch postgrestError.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 "PGRST204": return "postgrest_unknown_column" + case "PGRST301": return "postgrest_jwt_invalid" + default: return "postgrest_error" + } + } + + if error is URLError { return "network_error" } + if error is DecodingError { return "decode_error" } + return "unexpected_error" + } + + /// PostgREST error code for logs, `"none"` when not a PostgrestError. + static func postgrestCode(of error: Error) -> String { + (error as? PostgrestError)?.code ?? "none" + } + + /// Auth error code (status:code) for logs, `"none"` when not an AuthError. + static func authCode(of error: Error) -> String { + guard let authError = error as? AuthError else { return "none" } + switch authError { + 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" + } + } +} diff --git a/README.md b/README.md index 6dcade6..84e7aa5 100644 --- a/README.md +++ b/README.md @@ -228,34 +228,41 @@ cd AkFit-iOS Open the Xcode project in Xcode or Cursor. -### 3. Create a Supabase project +### 3. Configure local secrets -Create a Supabase project and keep track of: +Copy the template and fill in the values from the Supabase dashboard +(**Project Settings → API**): -- project URL -- publishable / anon key -- database credentials +```bash +cp AkFit/Config/Secrets.xcconfig.template AkFit/Config/Secrets.xcconfig +``` -### 4. Add Supabase to the app +Required keys (see the template's comments): -Add the Swift package dependency: +- `SUPABASE_URL` — note the `https:/$()/` escape, xcconfig treats `//` as a comment +- `SUPABASE_ANON_KEY` +- `SENTRY_DSN` — optional; the app runs without it -```text -https://github.com/supabase/supabase-swift.git -``` +`Debug.xcconfig` / `Release.xcconfig` include this file with `#include?` +(optional include), so a **missing file builds fine but crashes at first +launch** with an intentional, self-describing `fatalError` from +`AkFit/Config/AppConfig.swift`. If you hit that crash, the file or a key is +missing/malformed. -### 5. Configure local secrets +Do **not** commit `Secrets.xcconfig` — it is gitignored; only the template is +tracked. The `supabase-swift` package dependency is already part of the +project. -Create a local config file for secrets and environment-specific values. +### 4. Set up the database -Example: +Migrations, seed data, RLS tests, and the schema-drift verification procedure +live in [`supabase/README.md`](supabase/README.md): -```text -Secrets.xcconfig +```bash +supabase link --project-ref +supabase db push ``` -Do **not** commit real secrets to the repository. - --- ## Security notes @@ -275,24 +282,23 @@ Use Row Level Security and proper backend policies for user-owned data. ## Status -AkFit is currently in the **planning and foundation setup** phase. - -Current focus: +AkFit is **shipped on the App Store** (current version: see `MARKETING_VERSION` +in the Xcode project — 1.0.5 at the time of writing). -- repo setup -- UI reference organization -- Claude Code instruction system -- iOS project structure -- backend foundation planning +Live feature set: -Next major steps: +- onboarding → personalized calorie/macro targets (Mifflin-St Jeor) +- dashboard with daily calorie/macro tracking and previous-day backfill +- food search (Supabase catalog + Open Food Facts), barcode scanning +- food logging with favorites, recents, and swipe quick-log +- water, bodyweight, daily notes, grocery list +- Sign in with Apple / Google / email, guest mode, account deletion +- Apple Health export, reminders, Sentry monitoring -- initialize the Xcode app -- connect Supabase -- define the first MVP schema -- implement onboarding -- implement dashboard -- implement food search and logging flow +Current bias (see `CLAUDE.md` for the authoritative rules): crash prevention, +App Store compliance, auth/Health stability, and minimal-risk improvements — +not new feature work. Operational docs: `docs/release-checklist.md`, +`docs/onboarding-save-flow.md`, `docs/debugging-supabase-errors.md`. --- diff --git a/docs/debugging-supabase-errors.md b/docs/debugging-supabase-errors.md new file mode 100644 index 0000000..b257641 --- /dev/null +++ b/docs/debugging-supabase-errors.md @@ -0,0 +1,51 @@ +# Debugging Supabase Errors in AkFit + +Map from the error codes AkFit logs (os.log `classification` / +`postgrest_code` / `auth_code` fields, Sentry `akfit.*` tags) to their likely +cause and the next diagnostic step. + +## Where to look first + +1. **Sentry** (`talktoem` org): non-fatal events tagged `akfit.operation` + (`onboarding_save`, `edit_goal_save`, `edit_profile_save`, `quick_log`, + `user_data_fetch`, `delete_account`) with `akfit.classification` and + `akfit.postgrest_code` tags, plus release attribution. +2. **Console.app / Xcode console**: os.log categories `Onboarding`; DEBUG + builds also print `[OnboardingSave]`, `[AuthWrite]`, `[DeleteAccount]`. +3. **Supabase dashboard → Logs → API**: filter non-2xx; the request path tells + you the table. + +## PostgREST / Postgres codes + +| Code | Meaning | First suspect in AkFit | Next step | +|---|---|---|---| +| `23514` | check_violation | **Live-vs-migration constraint drift** (the 2026-06 `lean_bulk` incident) or an app payload outside the allowed set | Run the drift check in `supabase/README.md` → compare `pg_get_constraintdef` against the migration files | +| `42501` | insufficient_privilege (RLS denial) | A write verb with no policy — note `food_logs` and `bodyweight_logs` intentionally have **no UPDATE policy**; an `.update()` on them is a bug | Dump `pg_policies` for the table; check the app sends the session user's id | +| `23502` | not_null_violation | Payload omitted a NOT NULL column (encoder drops nil optionals) | Compare the Encodable payload struct against live `information_schema.columns.is_nullable` | +| `23503` | foreign_key_violation | `user_id` not present in `auth.users` (deleted account writing from a stale session) | Check whether the user still exists; expect auto-signout soon after | +| `23505` | unique_violation | `favorite_foods (user_id, food_name, serving_label)` or `daily_notes (user_id, note_date)` duplicates | Usually benign double-submit; check in-flight guards | +| `PGRST116` | zero/multiple rows where one expected (`.single()`) | Fetch found no row (treated as not-found in `AuthManager`) — or an UPDATE matched 0 rows (stale id, RLS filter) | Verify the row exists and the `eq` filters | +| `PGRST204` | column not in schema cache | App payload references a column the DB doesn't have (schema drift / unapplied migration) | `supabase migration list --linked`; apply pending migrations | +| `PGRST301` | JWT invalid/expired at PostgREST | Token expired mid-flight; classified as session-expired | The SDK refresh normally handles it; persistent → check device clock skew | + +## Auth (GoTrue) failures + +| Symptom | Meaning | Next step | +|---|---|---| +| `auth_session_missing` | No session when a write demanded one (`requireAuthenticatedUserIDForWrite` with `userState != .authenticated`) | Routing bug or guest-path leak — check `userState` transitions | +| `auth_api_error` + 401, codes `invalid_jwt`/`bad_jwt` | Server rejected the JWT | Device clock skew, or key rotation on the project | +| `refresh_token_already_used` / `session_not_found` in SDK logs | Fatal refresh failure | supabase-swift destroys the session and emits `.signedOut` → user lands on AuthView; expected recovery, no action | +| 401 on `/auth/v1/token` in API logs | Refresh token revoked/rotated | Check Auth → Sessions in the dashboard; user must sign in again | + +## 401/403 quick triage + +- **401 on `/rest/v1/*`**: expired/invalid JWT → PGRST301 path above. +- **403 / `42501` with a valid session**: RLS denial — the verb has no policy + or `auth.uid()` ≠ the row's `user_id`/`id`. Dump policies: + ```sql + select tablename, policyname, cmd, qual, with_check + from pg_policies where schemaname = 'public' and tablename = ''; + ``` +- **Failure that retries can't fix and local testing can't reproduce**: + assume schema drift until proven otherwise — run the drift check in + `supabase/README.md`. diff --git a/docs/onboarding-save-flow.md b/docs/onboarding-save-flow.md new file mode 100644 index 0000000..152be3d --- /dev/null +++ b/docs/onboarding-save-flow.md @@ -0,0 +1,90 @@ +# Onboarding Save Flow + +How the final onboarding step ("Start tracking") persists the user's profile +and goal, what happens when it partially fails, and how recovery works. + +Code: `AkFit/AkFit/Views/Onboarding/OnboardingView.swift` (`ResultsStepView.save()`), +`AkFit/Services/Supabase/ProfileService.swift`, `GoalService.swift`, +`AkFit/AkFit/Auth/AuthManager.swift` (`requireAuthenticatedUserIDForWrite`). + +## Sequence (authenticated path) + +```text +Start tracking tap + │ + ├─ 1. requireAuthenticatedUserIDForWrite() + │ AuthManager resolves a valid session via auth.session + │ (auto-refreshes an expired token; 2 attempts, 350ms apart). + │ Returns session.user.id — the ONLY user id used for the writes. + │ + ├─ 2. ProfileService.upsert → POST /rest/v1/profiles (on_conflict=id) + │ Writes display_name, height_cm, weight_kg, birthdate, sex, + │ activity_level, updated_at. IDEMPOTENT — safe to repeat. + │ + ├─ 3. GoalService.insert → POST /rest/v1/goals + │ Writes user_id, goal_type, target_pace (nil for maintenance), + │ daily_calories/protein/carbs/fat. Creates a NEW row each time + │ (goal history by design; fetchActiveGoal reads newest by created_at). + │ + └─ 4. authManager.markOnboarded(goal:profile:) + Sets in-memory state → isOnboarded == true → RootView routes to + MainTabView. No extra network round-trip. +``` + +Guest path: steps 1–3 are replaced by local `GuestDataStore` writes (UserDefaults). +No Supabase calls are made. + +## Partial-failure matrix + +The two writes are **not atomic**. This is intentional and safe: + +| Failure point | DB state afterwards | What the user sees | Recovery | +|---|---|---|---| +| Session resolution (step 1) | nothing written | "Session expired…" (fatal session failures also auto-sign-out via the SDK → AuthView) | Sign in again; onboarding restarts | +| Profile upsert (step 2) | nothing written | classified error message | Retry on the same screen | +| Goal insert (step 3) | **profile row exists, no goal** | classified error message | Retry re-upserts the profile (idempotent no-op) and re-attempts the insert | +| Decode of returned row | row IS written | error message despite saved data | Next app launch fetches the saved rows; if both exist the user routes straight to the dashboard | + +Key invariant: **`isOnboarded` is driven solely by the existence of a goal +row.** A user with a profile but no goal re-enters onboarding on next launch — +they are never stranded half-onboarded on the dashboard. + +## Error classification + +All three save surfaces (onboarding results, EditGoalView, EditProfileView) +share `SaveErrorClassification` (`AkFit/Services/Supabase/SaveErrorClassification.swift`): + +- `sessionExpired` → "Session expired. Please sign out and sign back in." +- `nonRetryable` (23502/23503/23514/42501/PGRST204) → "…server problem. Please + update to the latest version of AkFit, or contact support…" — **never** "try + again", because retrying a deterministic server reject cannot succeed +- `retryable` (everything else) → "…check your connection and try again." + +Unit tests: `AkFitTests/SaveErrorClassificationTests.swift`. + +## Observability + +On failure the results step: + +1. Logs a structured line via os.log (subsystem = bundle id, category + `Onboarding`): `step`, `table`, `action`, `session_validated`, + `classification`, `postgrest_code`, `auth_code` — all `privacy: .public` + (codes only, no PII). Filter in Console.app by category `Onboarding`. +2. Captures the error to Sentry via `SentryMonitoring.captureNonFatal` + (operation `onboarding_save`, same fields as tags). + +See `docs/debugging-supabase-errors.md` for the code → cause → next-step map. + +## The 2026-06 incident (why this doc exists) + +From launch until 2026-06-11, the live `goals_goal_type_check` constraint only +allowed `'muscle_gain'` (legacy hand-created table) while the app sends +`'lean_bulk'`. Every Lean Bulk user failed at step 3 with 23514 — profile +saved, goal rejected, "Please try again" shown, retry failed identically. +10 of 54 users were stuck in that state; none recovered. Fixed by migration +`20260611054748_fix_goals_goal_type_check`. Guards added since: + +- `supabase/tests/database/goals_constraint_shape.test.sql` (CI) +- `GoalTypeDatabaseContractTests` (app-side raw-value tripwire) +- the drift-check procedure in `supabase/README.md` +- non-retryable error copy + Sentry capture (this flow) diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..804566e --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,78 @@ +# AkFit Release Checklist + +Operational sequence for shipping an App Store build. Principles live in +`CLAUDE.md` (App Store / release rules); this is the step-by-step list. + +## 1. Pre-flight (before bumping anything) + +- [ ] `main` is green in CI (`.github/workflows/ci.yml` — unit tests + Supabase + RLS/constraint tests) +- [ ] **Supabase drift check** (see `supabase/README.md` → "Verifying live + schema matches migrations"): + - [ ] `supabase migration list --linked` — every local migration applied + remotely, no remote-only orphans + - [ ] `supabase db diff --linked` — no unexpected live objects + - [ ] CHECK-constraint dump matches the migration files (especially + `goals_goal_type_check`) +- [ ] Any migration the new binary depends on is **pushed to prod before the + binary ships** (the app has no schema-version negotiation) +- [ ] Supabase Security Advisors reviewed (dashboard → Advisors) + +## 2. Version bump + +- [ ] Bump `MARKETING_VERSION` (and `CURRENT_PROJECT_VERSION` for each upload) + in `AkFit/AkFit.xcodeproj` — both live in build settings, one place each +- [ ] Commit on a release branch (`Ak/app-store-update-x-y-z` pattern) + +## 3. Archive & upload + +- [ ] Xcode → Product → Archive (Any iOS Device, Release config) +- [ ] Organizer → Distribute → App Store Connect → Upload +- [ ] Confirm the build appears in App Store Connect → TestFlight + +## 4. Manual smoke test (TestFlight build, real device when possible) + +Core loop: +- [ ] Fresh install → onboarding end-to-end → **"Start tracking" saves** + (test all three goal types — fat loss, maintenance, **lean bulk**) +- [ ] Dashboard loads targets; calories/macros render +- [ ] Food search → log a food → totals update +- [ ] Swipe quick-log from Recents/Favorites (and once in airplane mode — + expect the "Couldn't log food" alert, not silence) +- [ ] Water quick-add; bodyweight log + +Auth: +- [ ] Sign in with Apple — new account AND returning account (Apple omits + name/email on return; must not block) +- [ ] Email sign-up / sign-in; sign out +- [ ] Kill + relaunch → session persists, routes straight to dashboard + +Settings: +- [ ] Edit targets (switch goal type, incl. to Lean Bulk) → saves +- [ ] Edit profile (body stats) → macros recalculate +- [ ] HealthKit Connect on a device without a prior grant — no crash +- [ ] Delete account → confirm → routed to auth screen; re-sign-in creates a + clean slate + +Appearance: +- [ ] Dark mode pass over dashboard, search, onboarding +- [ ] iPad layout sanity check if the change touched shared views + +## 5. Submission + +- [ ] Release notes / "what to test" via the `akfit-write-release-notes` skill +- [ ] Submit for review; answer App Review messages from the reviewer-response + playbook (minimal, factual, screenshots) + +## 6. Post-release monitoring (first 48h) + +- [ ] Sentry (`talktoem` org): new crash signatures, and non-fatal + `akfit.operation` events (`onboarding_save` failures = highest priority) +- [ ] Supabase dashboard → Logs → API: non-2xx rate on `/rest/v1/goals`, + `/rest/v1/profiles` +- [ ] Quick SQL health check (read-only): + ```sql + -- Users stuck mid-onboarding (profile but no goal) — should not grow: + select count(*) from public.profiles p + where not exists (select 1 from public.goals g where g.user_id = p.id); + ``` diff --git a/supabase/README.md b/supabase/README.md index 1aae1e4..997168a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -40,7 +40,15 @@ For a clean local rebuild (drops everything, replays migrations, then replays `s supabase db reset ``` -### Option 2 — Supabase Dashboard SQL editor +### Option 2 — Supabase Dashboard SQL editor (last resort only) + +> **Warning:** running migration SQL by hand in the dashboard bypasses the +> `schema_migrations` history, so the CLI can no longer tell what has been +> applied — and any divergence between the file and what actually ran is +> invisible. Hand-applied SQL is exactly how the live `goals` table ended up +> with a `goal_type` check the migrations never declared (the 2026-06 +> onboarding incident). If you must use this path, run the drift check below +> immediately afterwards and repair history with `supabase migration repair`. 1. Open the project at `https://supabase.com/dashboard/project/cofakxwmrxauqtdldilx` 2. Navigate to **SQL Editor** @@ -68,6 +76,9 @@ supabase db reset | `20260402000014_data_cleanup_search_text` | Adds normalized `search_text` column, auto-populate trigger, and trigram GIN index on `generic_foods` | | `20260403000001_search_improvements` | Updates the `generic_foods` `search_text` trigger to also strip commas | | `20260416000000_goals_profiles_update_with_check` | Adds `WITH CHECK` to UPDATE policies on `goals` and `profiles` to close a cross-user write hole | +| `20260513000000_security_advisor_cleanup` | Security Advisor hardening: pins `search_path` on trigger functions, revokes app-facing execute on `rls_auto_enable()`. No behavior change | +| `20260514000000_water_entries` | Creates `water_entries` (per-user water intake events) with full CRUD RLS incl. `WITH CHECK` | +| `20260611054748_fix_goals_goal_type_check` | **Fixes the live `goals_goal_type_check` to accept `lean_bulk`** — the live table predated migration tracking and only allowed the legacy `muscle_gain` value, deterministically failing every Lean Bulk onboarding save (SQLSTATE 23514). See the drift check section below | Food catalog rows that do not change schema live in `supabase/seeds/food/` (see that folder's README for the file-by-file list). @@ -173,6 +184,17 @@ One free-text note per user per date. Unique on `(user_id, note_date)`. ### `grocery_items` Persistent shopping list. `is_checked` boolean + `sort_order` int for ordering. +### `water_entries` +Per-user water intake events; the Dashboard sums the day's rows. + +| Column | Type | Notes | +|---|---|---| +| `id` | uuid PK | `gen_random_uuid()` | +| `user_id` | uuid | FK → `auth.users(id)`, cascade delete | +| `amount_ml` | int | > 0 and ≤ 5000 | +| `logged_at` | timestamptz | Default `now()` | +| `created_at` | timestamptz | Default `now()` | + --- ## RLS verification @@ -187,7 +209,8 @@ where schemaname = 'public' and tablename in ( 'profiles', 'goals', 'food_logs', 'generic_foods', 'favorite_foods', - 'bodyweight_logs', 'daily_notes', 'grocery_items' + 'bodyweight_logs', 'daily_notes', 'grocery_items', + 'water_entries' ) order by tablename; @@ -204,8 +227,66 @@ Behavior tests live in `supabase/tests/database/`: - `rls_policy_shape.test.sql` — checks that policies exist with the expected shape - `rls_behavior.test.sql` — exercises actual cross-user reads/writes to confirm RLS blocks them +- `goals_constraint_shape.test.sql` — asserts `goals_goal_type_check` accepts exactly the app's `GoalType` raw values (regression guard for the 2026-06 incident) + +The `CI` GitHub workflow (`.github/workflows/ci.yml`) runs these against a local Supabase stack on every PR. + +--- + +## Verifying live schema matches migrations (drift check) + +**Why this exists:** migration files can lie about production. `CREATE TABLE IF +NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` silently no-op when the object already +exists, so any table that was ever hand-created in the dashboard may carry +constraints the files never declared. That is exactly how +`goals_goal_type_check` enforced `'muscle_gain'` in production while every +migration file said `'lean_bulk'` — failing 100% of Lean Bulk onboarding saves +(SQLSTATE 23514) for two months with zero failed CI runs. + +Run this check **after every `db push`, before every App Store release**, and +any time a write fails with `23514`/`42501` that local testing can't reproduce: + +```bash +# 1. Every local migration must appear as applied on the remote (and vice versa). +supabase migration list --linked + +# 2. Surface any live objects that differ from what the migrations build. +supabase db diff --linked +``` + +Then dump the live CHECK constraints and compare against the migrations: + +```sql +-- Run read-only in the SQL editor (or psql). Expected values are in the +-- migration files; the critical one: +-- goals_goal_type_check → (goal_type IN ('fat_loss','maintenance','lean_bulk')) +select conrelid::regclass as "table", + conname, + pg_get_constraintdef(oid) as definition +from pg_constraint +where connamespace = 'public'::regnamespace + and contype = 'c' +order by 1, 2; + +-- And the FK delete rules (account deletion relies on ON DELETE CASCADE): +select conrelid::regclass as "table", + conname, + pg_get_constraintdef(oid) as definition +from pg_constraint +where connamespace = 'public'::regnamespace + and contype = 'f' +order by 1, 2; +``` + +If anything differs from the migration files, do **not** edit the dashboard. +Write a new migration that explicitly `DROP`s and re-`ADD`s the drifted object +(the pattern used by `20260611054748_fix_goals_goal_type_check`), apply it with +`supabase db push`, and re-run the check. -The `CI` GitHub workflow (`.github/workflows/ci.yml`) runs both files against a local Supabase stack on every PR. +Known accepted drift (live objects not yet tracked in migrations): +`handle_updated_at()` + the `updated_at` triggers on `profiles`/`goals`, and +the `rls_auto_enable()` helper. `profiles.height_cm`/`weight_kg` are `numeric` +live (files say `integer`) — harmless, the app decodes both. --- diff --git a/supabase/migrations/20260611054748_fix_goals_goal_type_check.sql b/supabase/migrations/20260611054748_fix_goals_goal_type_check.sql new file mode 100644 index 0000000..5d9204a --- /dev/null +++ b/supabase/migrations/20260611054748_fix_goals_goal_type_check.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- Migration: 20260611054748_fix_goals_goal_type_check +-- Purpose: Fix the live goals_goal_type_check constraint so 'lean_bulk' is +-- accepted. This is the root cause of every onboarding final-save +-- failure since launch for users who chose "Lean Bulk". +-- +-- ── Root cause ─────────────────────────────────────────────────────────────── +-- The live public.goals table was hand-created in the dashboard before +-- migration tracking was reconciled, with: +-- +-- CHECK (goal_type IN ('fat_loss', 'maintenance', 'muscle_gain')) +-- +-- The app has always sent goal_type = 'lean_bulk' +-- (UserGoal.GoalType.leanBulk.rawValue). Result: every goals INSERT (onboarding +-- results step) and every UPDATE switching to Lean Bulk (EditGoalView) failed +-- with SQLSTATE 23514 (check_violation) — deterministically, on every retry, +-- on every app version. +-- +-- The tracked migrations (20260329000000, 20260401000014) declare the correct +-- 'lean_bulk' set, but 20260401000014 used CREATE TABLE IF NOT EXISTS, which +-- silently no-opped against the pre-existing live table — so the corrected +-- constraint never reached production. Verified live on 2026-06-11: +-- * pg_get_constraintdef showed the 'muscle_gain' variant +-- * goals distribution: 32 fat_loss, 8 maintenance, 0 lean_bulk ever +-- * 10 users stuck with a profile but no goal (profile upsert succeeds, +-- goal insert rejected), repeatedly retried, never recovered +-- +-- ── Fix ────────────────────────────────────────────────────────────────────── +-- 1. Normalize any legacy 'muscle_gain' rows to 'lean_bulk' (0 rows exist as +-- of 2026-06-11; the UPDATE is a defensive no-op kept for idempotency on +-- any environment where such rows might exist). +-- 2. Drop and re-add the constraint with the canonical app value set. +-- +-- ALTER TABLE ... ADD CONSTRAINT validates existing rows (40 rows live — +-- instantaneous). Runs in one transaction; no RLS, policy, index, or data +-- changes beyond the defensive normalization. +-- +-- Recovery: the stuck users remain routed to onboarding (no goal row => +-- isOnboarded == false). Their next "Start tracking" tap re-upserts the +-- profile (idempotent) and the goal insert now succeeds. No data repair needed. +-- ============================================================================= + +update public.goals + set goal_type = 'lean_bulk' + where goal_type = 'muscle_gain'; + +alter table public.goals + drop constraint if exists goals_goal_type_check; + +alter table public.goals + add constraint goals_goal_type_check + check (goal_type in ('fat_loss', 'maintenance', 'lean_bulk')); diff --git a/supabase/tests/database/goals_constraint_shape.test.sql b/supabase/tests/database/goals_constraint_shape.test.sql new file mode 100644 index 0000000..e5cfc8b --- /dev/null +++ b/supabase/tests/database/goals_constraint_shape.test.sql @@ -0,0 +1,70 @@ +begin; + +create extension if not exists pgtap with schema extensions; +set local search_path = extensions, public; + +select plan(5); + +-- ============================================================================= +-- goals_goal_type_check shape +-- +-- Regression guard for the 2026-06 incident: the live constraint allowed +-- 'muscle_gain' while the app sends 'lean_bulk', so every Lean Bulk +-- onboarding save failed with SQLSTATE 23514. Fixed by +-- 20260611054748_fix_goals_goal_type_check.sql. +-- +-- The app-side mirror of this contract is +-- AkFitTests/SaveErrorClassificationTests.swift +-- (GoalTypeDatabaseContractTests). +-- ============================================================================= + +select ok( + exists ( + select 1 + from pg_constraint + where conrelid = 'public.goals'::regclass + and conname = 'goals_goal_type_check' + and contype = 'c' + ), + 'goals_goal_type_check exists on public.goals' +); + +select matches( + (select pg_get_constraintdef(oid) + from pg_constraint + where conrelid = 'public.goals'::regclass + and conname = 'goals_goal_type_check'), + 'fat_loss', + 'goal_type check accepts fat_loss' +); + +select matches( + (select pg_get_constraintdef(oid) + from pg_constraint + where conrelid = 'public.goals'::regclass + and conname = 'goals_goal_type_check'), + 'maintenance', + 'goal_type check accepts maintenance' +); + +select matches( + (select pg_get_constraintdef(oid) + from pg_constraint + where conrelid = 'public.goals'::regclass + and conname = 'goals_goal_type_check'), + 'lean_bulk', + 'goal_type check accepts lean_bulk (the 2026-06 incident fix)' +); + +select doesnt_match( + (select pg_get_constraintdef(oid) + from pg_constraint + where conrelid = 'public.goals'::regclass + and conname = 'goals_goal_type_check'), + 'muscle_gain', + 'goal_type check no longer references the legacy muscle_gain value' +); + +select * from finish(); + +rollback;