From d7f55110ba505624aa694f054e929553928a0980 Mon Sep 17 00:00:00 2001 From: Fredrik Liljegren Date: Sun, 22 Feb 2026 14:04:20 +0100 Subject: [PATCH] Add partial Health Connect permissions + DB migration retry Android: - Add HealthConnectPermissions.kt with 6 user-friendly data categories (Activity, Heart & Vitals, Body, Sleep, Nutrition, Reproductive) - Refactor permission state from all-or-nothing boolean to granular grantedPermissions set with derived hasAny/hasAll/categoryStatuses - Redesign sync screen with card-based UI: sync status card, per-category permission cards with individual Grant buttons, Sync Now button - Remove debug-style raw record list - Update background sync worker for partial permissions: filter aggregatable metrics to granted types, skip only if zero permissions - Invalidate changes token when granted types change (forces re-fetch) Backend: - Add schema error detection (42P01 undefined_table, 42703 undefined_column) - Add per-user migration mutex to coalesce concurrent migration attempts - Add automatic migration retry in query() when called with username (not when called with Client directly, preventing infinite recursion) - Login migration remains as eager/happy path; query retry is safety net Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../net/aurboda/HealthConnectPermissions.kt | 132 +++++ .../net/aurboda/HealthConnectSyncWorker.kt | 72 ++- .../src/main/java/net/aurboda/MainActivity.kt | 541 ++++++++++-------- .../aurboda/HealthConnectPermissionsTest.kt | 146 +++++ apps/backend/src/db/connection.test.ts | 168 ++++++ apps/backend/src/db/connection.ts | 48 +- 6 files changed, 848 insertions(+), 259 deletions(-) create mode 100644 apps/android/app/src/main/java/net/aurboda/HealthConnectPermissions.kt create mode 100644 apps/android/app/src/test/java/net/aurboda/HealthConnectPermissionsTest.kt create mode 100644 apps/backend/src/db/connection.test.ts diff --git a/apps/android/app/src/main/java/net/aurboda/HealthConnectPermissions.kt b/apps/android/app/src/main/java/net/aurboda/HealthConnectPermissions.kt new file mode 100644 index 0000000..25e089a --- /dev/null +++ b/apps/android/app/src/main/java/net/aurboda/HealthConnectPermissions.kt @@ -0,0 +1,132 @@ +package net.aurboda + +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.* +import kotlin.reflect.KClass + +/** + * A user-friendly grouping of Health Connect record types into categories. + */ +data class HealthDataCategory( + val name: String, + val recordTypes: List>, + val description: String +) + +/** + * Permission status for a single category. + */ +data class CategoryPermissionStatus( + val category: HealthDataCategory, + val grantedCount: Int, + val totalCount: Int +) { + val allGranted: Boolean get() = grantedCount == totalCount + val noneGranted: Boolean get() = grantedCount == 0 + val partiallyGranted: Boolean get() = grantedCount in 1 until totalCount +} + +val healthDataCategories: List = listOf( + HealthDataCategory( + name = "Activity & Exercise", + recordTypes = listOf( + StepsRecord::class, + DistanceRecord::class, + ActiveCaloriesBurnedRecord::class, + TotalCaloriesBurnedRecord::class, + ExerciseSessionRecord::class, + SpeedRecord::class, + PowerRecord::class, + FloorsClimbedRecord::class, + CyclingPedalingCadenceRecord::class, + ElevationGainedRecord::class, + Vo2MaxRecord::class, + WheelchairPushesRecord::class + ), + description = "Steps, distance, calories, exercise sessions, and more" + ), + HealthDataCategory( + name = "Heart & Vitals", + recordTypes = listOf( + HeartRateRecord::class, + HeartRateVariabilityRmssdRecord::class, + RestingHeartRateRecord::class, + BloodPressureRecord::class, + OxygenSaturationRecord::class, + RespiratoryRateRecord::class, + BloodGlucoseRecord::class, + BasalMetabolicRateRecord::class + ), + description = "Heart rate, HRV, blood pressure, SpO2, and more" + ), + HealthDataCategory( + name = "Body Measurements", + recordTypes = listOf( + WeightRecord::class, + HeightRecord::class, + BodyFatRecord::class, + LeanBodyMassRecord::class, + BoneMassRecord::class, + BodyWaterMassRecord::class, + BodyTemperatureRecord::class, + BasalBodyTemperatureRecord::class + ), + description = "Weight, height, body composition, and temperature" + ), + HealthDataCategory( + name = "Sleep", + recordTypes = listOf( + SleepSessionRecord::class + ), + description = "Sleep sessions and stages" + ), + HealthDataCategory( + name = "Nutrition & Hydration", + recordTypes = listOf( + NutritionRecord::class, + HydrationRecord::class + ), + description = "Food intake and hydration" + ), + HealthDataCategory( + name = "Reproductive Health", + recordTypes = listOf( + CervicalMucusRecord::class, + IntermenstrualBleedingRecord::class, + MenstruationFlowRecord::class, + MenstruationPeriodRecord::class, + OvulationTestRecord::class, + SexualActivityRecord::class + ), + description = "Menstrual cycle, ovulation, and related data" + ) +) + +/** + * Filter allRecordTypes to only those with a granted read permission. + */ +fun getGrantedRecordTypes( + grantedPermissions: Set, + recordTypes: List> = allRecordTypes +): List> = + recordTypes.filter { recordType -> + HealthPermission.getReadPermission(recordType) in grantedPermissions + } + +/** + * Compute per-category permission status from the set of granted permissions. + */ +fun getCategoryStatuses( + grantedPermissions: Set, + categories: List = healthDataCategories +): List = + categories.map { category -> + val grantedCount = category.recordTypes.count { recordType -> + HealthPermission.getReadPermission(recordType) in grantedPermissions + } + CategoryPermissionStatus( + category = category, + grantedCount = grantedCount, + totalCount = category.recordTypes.size + ) + } diff --git a/apps/android/app/src/main/java/net/aurboda/HealthConnectSyncWorker.kt b/apps/android/app/src/main/java/net/aurboda/HealthConnectSyncWorker.kt index 830b42c..74e3377 100644 --- a/apps/android/app/src/main/java/net/aurboda/HealthConnectSyncWorker.kt +++ b/apps/android/app/src/main/java/net/aurboda/HealthConnectSyncWorker.kt @@ -6,7 +6,6 @@ import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.aggregate.AggregateMetric import androidx.health.connect.client.changes.DeletionChange import androidx.health.connect.client.changes.UpsertionChange -import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.* import androidx.health.connect.client.request.AggregateRequest import androidx.health.connect.client.request.ChangesTokenRequest @@ -26,7 +25,6 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -37,10 +35,12 @@ import net.aurboda.api.models.DailyAggregate import net.aurboda.api.models.DailyAggregatesBody import net.aurboda.widget.HrZoneWidgetProvider import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass private const val TAG = "HealthConnectSyncWorker" private const val PREFS_NAME = "AurbodaAppPrefs" private const val CHANGES_TOKEN_KEY = "healthConnectChangesToken" +private const val GRANTED_TYPES_KEY = "grantedRecordTypeNames" private const val WORK_NAME = "health_connect_sync" class HealthConnectSyncWorker( @@ -55,13 +55,19 @@ class HealthConnectSyncWorker( } } - // Cumulative metrics that should be aggregated to avoid duplication - private val aggregatableMetrics: List, DailyAggregate.Metric>> = listOf( - Pair(StepsRecord.COUNT_TOTAL, DailyAggregate.Metric.steps), - Pair(DistanceRecord.DISTANCE_TOTAL, DailyAggregate.Metric.distance), - Pair(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL, DailyAggregate.Metric.calories_active), - Pair(TotalCaloriesBurnedRecord.ENERGY_TOTAL, DailyAggregate.Metric.calories_total), - Pair(FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL, DailyAggregate.Metric.floors_climbed) + // Cumulative metrics with the record class they require permission for + private data class AggregatableMetric( + val aggregateMetric: AggregateMetric<*>, + val dailyMetric: DailyAggregate.Metric, + val recordClass: KClass + ) + + private val allAggregatableMetrics: List = listOf( + AggregatableMetric(StepsRecord.COUNT_TOTAL, DailyAggregate.Metric.steps, StepsRecord::class), + AggregatableMetric(DistanceRecord.DISTANCE_TOTAL, DailyAggregate.Metric.distance, DistanceRecord::class), + AggregatableMetric(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL, DailyAggregate.Metric.calories_active, ActiveCaloriesBurnedRecord::class), + AggregatableMetric(TotalCaloriesBurnedRecord.ENERGY_TOTAL, DailyAggregate.Metric.calories_total, TotalCaloriesBurnedRecord::class), + AggregatableMetric(FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL, DailyAggregate.Metric.floors_climbed, FloorsClimbedRecord::class) ) override suspend fun doWork(): Result { @@ -73,17 +79,25 @@ class HealthConnectSyncWorker( return Result.success() } - // Check permissions - val permissions = allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() + // Check permissions — proceed with whatever is granted (partial permissions support) val grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions() - if (!grantedPermissions.containsAll(permissions)) { - Log.w(TAG, "Not all permissions granted, skipping sync") + val grantedTypes = getGrantedRecordTypes(grantedPermissions) + if (grantedTypes.isEmpty()) { + Log.w(TAG, "No permissions granted, skipping sync") return Result.success() } + Log.d(TAG, "Granted ${grantedTypes.size}/${allRecordTypes.size} record types") + + // Invalidate token if granted types changed since last sync + invalidateTokenIfGrantedTypesChanged(grantedTypes) + + // Filter aggregatable metrics to only those with granted permissions + val grantedTypeSet = grantedTypes.toSet() + val activeAggregateMetrics = allAggregatableMetrics.filter { it.recordClass in grantedTypeSet } return try { // Step 1: Fetch and send daily aggregates for cumulative metrics (deduplicated) - val aggregates = fetchDailyAggregates(days = 7) + val aggregates = fetchDailyAggregates(activeAggregateMetrics, days = 7) if (aggregates.isNotEmpty()) { val aggregateSuccess = sendDailyAggregates(aggregates, credentials.apiUrl, credentials.authToken) if (!aggregateSuccess) { @@ -381,11 +395,35 @@ class HealthConnectSyncWorker( return prefs.getString(CHANGES_TOKEN_KEY, null) } + /** + * Check if granted types changed and invalidate the changes token if so. + */ + private fun invalidateTokenIfGrantedTypesChanged(currentGrantedTypes: List>) { + val prefs = applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val currentNames = currentGrantedTypes.map { it.simpleName ?: "" }.sorted().joinToString(",") + val savedNames = prefs.getString(GRANTED_TYPES_KEY, null) + + if (savedNames != null && savedNames != currentNames) { + Log.d(TAG, "Granted types changed, invalidating changes token") + prefs.edit() + .remove(CHANGES_TOKEN_KEY) + .putString(GRANTED_TYPES_KEY, currentNames) + .apply() + } else { + prefs.edit().putString(GRANTED_TYPES_KEY, currentNames).apply() + } + } + /** * Fetch daily aggregates for cumulative metrics using Health Connect's aggregate() API. - * This automatically deduplicates based on user-configured app priority. + * Only fetches for metrics that have granted permissions. */ - private suspend fun fetchDailyAggregates(days: Int = 7): List { + private suspend fun fetchDailyAggregates( + metrics: List, + days: Int = 7 + ): List { + if (metrics.isEmpty()) return emptyList() + val aggregates = mutableListOf() val today = LocalDate.now() val zoneId = ZoneId.systemDefault() @@ -395,7 +433,7 @@ class HealthConnectSyncWorker( val startTime = date.atStartOfDay(zoneId).toInstant() val endTime = date.plusDays(1).atStartOfDay(zoneId).toInstant() - for ((metric, metricType) in aggregatableMetrics) { + for ((metric, metricType, _) in metrics) { try { val request = AggregateRequest( metrics = setOf(metric), diff --git a/apps/android/app/src/main/java/net/aurboda/MainActivity.kt b/apps/android/app/src/main/java/net/aurboda/MainActivity.kt index f086287..7a82648 100644 --- a/apps/android/app/src/main/java/net/aurboda/MainActivity.kt +++ b/apps/android/app/src/main/java/net/aurboda/MainActivity.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -37,7 +36,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -58,7 +56,6 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -80,16 +77,13 @@ import net.aurboda.update.installApk // Import allRecordTypes from HealthDataModels import net.aurboda.allRecordTypes import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import kotlin.reflect.KClass private const val PREFS_NAME = "AurbodaAppPrefs" private const val CHANGES_TOKEN_KEY = "healthConnectChangesToken" private const val BACKGROUND_SYNC_ENABLED_KEY = "backgroundSyncEnabled" +private const val GRANTED_TYPES_KEY = "grantedRecordTypeNames" private fun isBackgroundSyncEnabled(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -186,6 +180,29 @@ private fun loadChangesToken(context: Context): String? { return token } +/** + * Check if the set of granted record types has changed since last fetch. + * If changed, invalidate the changes token to force a full re-fetch. + */ +private fun invalidateTokenIfGrantedTypesChanged( + context: Context, + currentGrantedTypes: List> +) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val currentNames = currentGrantedTypes.map { it.simpleName ?: "" }.sorted().joinToString(",") + val savedNames = prefs.getString(GRANTED_TYPES_KEY, null) + + if (savedNames != null && savedNames != currentNames) { + Log.d("TokenManager", "Granted types changed, invalidating changes token") + prefs.edit() + .remove(CHANGES_TOKEN_KEY) + .putString(GRANTED_TYPES_KEY, currentNames) + .apply() + } else { + prefs.edit().putString(GRANTED_TYPES_KEY, currentNames).apply() + } +} + private const val SEND_DATA_TAG = "SendData" private suspend inline fun handlePostData( @@ -391,7 +408,25 @@ fun HealthConnectScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val healthConnectClient = remember { HealthConnectClient.getOrCreate(context) } - var hasPermissions by remember { mutableStateOf(false) } + + // -- Permission state (partial permissions support) -- + var grantedPermissions by remember { mutableStateOf>(emptySet()) } + val grantedRecordTypes by remember(grantedPermissions) { + derivedStateOf { getGrantedRecordTypes(grantedPermissions) } + } + val hasAnyPermissions by remember(grantedRecordTypes) { + derivedStateOf { grantedRecordTypes.isNotEmpty() } + } + val hasAllPermissions by remember(grantedPermissions) { + derivedStateOf { + val allPermissions = allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() + grantedPermissions.containsAll(allPermissions) + } + } + val categoryStatuses by remember(grantedPermissions) { + derivedStateOf { getCategoryStatuses(grantedPermissions) } + } + var healthRecords by remember { mutableStateOf>(emptyList()) } var pendingDeletionIds by remember { mutableStateOf>(emptyList()) } var isProcessing by remember { mutableStateOf(false) } @@ -403,7 +438,6 @@ fun HealthConnectScreen( val batteryOptimizationLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { - // Check if exemption was granted after returning from settings if (isIgnoringBatteryOptimizations(context)) { Log.d("BatteryOptimization", "Battery optimization exemption granted") } else { @@ -412,22 +446,27 @@ fun HealthConnectScreen( } val scope = rememberCoroutineScope() - val permissions = remember(allRecordTypes) { allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() } + val allPermissions = remember(allRecordTypes) { allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() } val ktorHttpClient = remember { HttpClient(Android) { install(ContentNegotiation) { json(appJson) } } } suspend fun fetchHealthData(currentActiveContext: Context) { - if (!hasPermissions) { - statusMessage = "Permissions not granted. Cannot fetch data." - Log.d("HealthConnectScreen", "fetchHealthData called but no permissions.") - return + if (grantedRecordTypes.isEmpty()) { + statusMessage = "No permissions granted. Cannot fetch data." + Log.d("HealthConnectScreen", "fetchHealthData called but no granted types.") + return } - if(isProcessing) { - Log.d("HealthConnectScreen", "fetchHealthData called while already processing (concurrent call). Bailing.") + if (isProcessing) { + Log.d("HealthConnectScreen", "fetchHealthData called while already processing. Bailing.") return } - isProcessing = true + isProcessing = true statusMessage = "Fetching data from Health Connect..." - Log.d("HealthConnectScreen", "Starting data fetch for ${allRecordTypes.size} types...") + val typesToFetch = grantedRecordTypes + Log.d("HealthConnectScreen", "Starting data fetch for ${typesToFetch.size} granted types...") + + // Invalidate token if the set of granted types has changed + invalidateTokenIfGrantedTypesChanged(currentActiveContext, typesToFetch) + val localHealthRecords = mutableListOf() val localDeletionIds = mutableListOf() var localPendingTokenToPersist: String? = null @@ -440,7 +479,7 @@ fun HealthConnectScreen( try { val sevenDaysAgo = ZonedDateTime.now().minusDays(7).toInstant() val now = Instant.now() - for (recordType: KClass in allRecordTypes) { + for (recordType: KClass in typesToFetch) { try { @Suppress("UNCHECKED_CAST") val specificRecordType = recordType as KClass @@ -450,32 +489,32 @@ fun HealthConnectScreen( ascendingOrder = false ) val recordsOfType = healthConnectClient.readRecords(request).records - if(recordsOfType.isNotEmpty()) { + if (recordsOfType.isNotEmpty()) { Log.d("FetchData", "Fetched ${recordsOfType.size} records of type ${recordType.simpleName}") localHealthRecords.addAll(recordsOfType) } } catch (e: Exception) { - Log.w("FetchData", "Error fetching ${recordType.simpleName}: ${e.message}. May require specific permissions not yet handled or type not available.") + Log.w("FetchData", "Error fetching ${recordType.simpleName}: ${e.message}") } } - Log.d("FetchData", "Initial fetch process complete. Total ${localHealthRecords.size} records fetched.") + Log.d("FetchData", "Initial fetch complete. Total ${localHealthRecords.size} records.") if (localHealthRecords.isNotEmpty()) { - val initialToken = healthConnectClient.getChangesToken(ChangesTokenRequest(allRecordTypes.toSet())) + val initialToken = healthConnectClient.getChangesToken(ChangesTokenRequest(typesToFetch.toSet())) localPendingTokenToPersist = initialToken statusMessage = "Fetched ${localHealthRecords.size} initial records. Ready to send." } else { - statusMessage = "No records found during initial fetch for any type." + statusMessage = "No records found during initial fetch." try { - val initialToken = healthConnectClient.getChangesToken(ChangesTokenRequest(allRecordTypes.toSet())) - saveChangesToken(currentActiveContext, initialToken) - Log.d("FetchData", "Saved initial token as no data was found: ${initialToken.take(10)}...") + val initialToken = healthConnectClient.getChangesToken(ChangesTokenRequest(typesToFetch.toSet())) + saveChangesToken(currentActiveContext, initialToken) + Log.d("FetchData", "Saved initial token (no data): ${initialToken.take(10)}...") } catch (e: Exception) { - Log.e("FetchData", "Failed to get/save initial changes token when no initial data found.", e) - statusMessage = "Error initializing token with no data." + Log.e("FetchData", "Failed to get/save initial changes token.", e) + statusMessage = "Error initializing token." } } } catch (e: Exception) { - Log.e("FetchData", "Error during overall initial data fetch from Health Connect.", e) + Log.e("FetchData", "Error during initial data fetch.", e) statusMessage = "Error fetching initial data: ${e.message}" fetchSuccessful = false } @@ -486,18 +525,16 @@ fun HealthConnectScreen( var totalUpsertions = 0 var hasMore = true - // Loop to fetch all changes until hasMore is false while (hasMore) { val changesResponse = healthConnectClient.getChanges(currentToken) val upsertions = changesResponse.changes.mapNotNull { if (it is UpsertionChange) it.record else null } if (upsertions.isNotEmpty()) { - Log.d("FetchData", "Adding ${upsertions.size} upserted records to list.") + Log.d("FetchData", "Adding ${upsertions.size} upserted records.") localHealthRecords.addAll(upsertions) totalUpsertions += upsertions.size } changesResponse.changes.forEach { if (it is DeletionChange) { - Log.d("HealthConnect", "Record deleted, ID: ${it.recordId}") localDeletionIds.add(it.recordId) } } @@ -506,17 +543,14 @@ fun HealthConnectScreen( currentToken = changesResponse.nextChangesToken if (hasMore) { - Log.d("FetchData", "More changes available, continuing fetch...") statusMessage = "Fetching more data... ($totalUpsertions records so far)" } } localPendingTokenToPersist = currentToken - Log.d("FetchData", "Fetched $totalUpsertions total upsertions, ${localDeletionIds.size} deletions. Next token candidate: ${localPendingTokenToPersist?.take(10)}...") if (totalUpsertions == 0 && localDeletionIds.isEmpty()) { statusMessage = "No new changes found." saveChangesToken(currentActiveContext, localPendingTokenToPersist) - Log.d("FetchData", "Saved next changes token as no new data was found: ${localPendingTokenToPersist?.take(10)}...") localPendingTokenToPersist = null } else { val parts = mutableListOf() @@ -525,7 +559,7 @@ fun HealthConnectScreen( statusMessage = "Fetched ${parts.joinToString(", ")}. Ready to send." } } catch (e: Exception) { - Log.e("FetchData", "Error fetching changes from Health Connect.", e) + Log.e("FetchData", "Error fetching changes.", e) statusMessage = "Error fetching changes: ${e.message}" fetchSuccessful = false } @@ -540,43 +574,49 @@ fun HealthConnectScreen( pendingDeletionIds = emptyList() pendingTokenToPersist = null } - Log.d("HealthConnectScreen", "Data fetch processing finished. status: $statusMessage") - isProcessing = false + Log.d("HealthConnectScreen", "Data fetch finished. status: $statusMessage") + isProcessing = false + } + + /** Re-query actual granted permissions from system after launcher returns. */ + suspend fun refreshPermissions() { + grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions() + val count = grantedRecordTypes.size + Log.d("HealthConnect", "Permissions refreshed: $count/${allRecordTypes.size} types granted") + if (count > 0) { + statusMessage = "$count of ${allRecordTypes.size} data types authorized." + } else { + statusMessage = "No permissions granted." + } } val requestPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() - ) { permissionsMap -> - val allGranted = permissionsMap.values.all { it } - hasPermissions = allGranted - if (allGranted) { - Log.d("HealthConnect", "All permissions granted via launcher. Attempting to fetch data.") - scope.launch { fetchHealthData(context) } - } else { - val deniedPermissions = permissionsMap.filter { !it.value }.keys - val deniedCount = deniedPermissions.size - Log.w("HealthConnect", "Not all permissions were granted via launcher. Denied ($deniedCount): $deniedPermissions") - statusMessage = "$deniedCount permission(s) were denied. Health data may be incomplete. Missing: ${deniedPermissions.joinToString()}" - isProcessing = false + ) { _ -> + // Don't trust the launcher result — re-query actual permissions from system + scope.launch { + refreshPermissions() + if (grantedRecordTypes.isNotEmpty()) { + fetchHealthData(context) + } else { + isProcessing = false + } } } suspend fun checkPermissionsAndFetchData(coroutineScope: CoroutineScope, currentContext: Context) { - val grantedPermissionsSet = healthConnectClient.permissionController.getGrantedPermissions() - if (grantedPermissionsSet.containsAll(permissions)) { - hasPermissions = true - Log.d("HealthConnect", "Permissions are already granted. Will attempt to fetch data.") + grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions() + val grantedCount = grantedRecordTypes.size + Log.d("HealthConnect", "Permission check: $grantedCount/${allRecordTypes.size} types granted") + + if (grantedCount > 0) { + statusMessage = "$grantedCount of ${allRecordTypes.size} data types authorized." coroutineScope.launch { fetchHealthData(currentContext) } } else { - hasPermissions = false - val missingPermissions = permissions.subtract(grantedPermissionsSet) - Log.w("HealthConnect", "Permissions check failed. App needs ${permissions.size}, granted ${grantedPermissionsSet.size}.") - Log.w("HealthConnect", "Expected permissions: ${permissions.joinToString()}") - Log.w("HealthConnect", "Granted permissions: ${grantedPermissionsSet.joinToString()}") - Log.w("HealthConnect", "Missing permissions (${missingPermissions.size}): ${missingPermissions.joinToString()}") - statusMessage = "${missingPermissions.size} permission(s) not granted. Requesting. Missing: ${missingPermissions.joinToString { it.substringAfterLast(".") } }" - isProcessing = false - requestPermissionLauncher.launch(permissions.toTypedArray()) + // No permissions at all — request everything + statusMessage = "No permissions granted. Requesting access..." + isProcessing = false + requestPermissionLauncher.launch(allPermissions.toTypedArray()) } } @@ -586,8 +626,8 @@ fun HealthConnectScreen( Log.d("SendData", "No records or deletions to send.") return } - if(isProcessing){ - Log.d("SendData", "sendPendingDataToServer called while already processing.") + if (isProcessing) { + Log.d("SendData", "sendPendingDataToServer called while already processing.") return } isProcessing = true @@ -637,7 +677,7 @@ fun HealthConnectScreen( if (recordsWithKnownSerializers.isEmpty()) { Log.d("SendData", "No records with known serializers to send.") } else { - Log.d("SendData", "Attempting to send ${recordsWithKnownSerializers.size} records (${healthRecords.size - recordsWithKnownSerializers.size} unsupported types excluded).") + Log.d("SendData", "Sending ${recordsWithKnownSerializers.size} records.") val groupedRecords = recordsWithKnownSerializers.groupBy { it::class } for ((recordClass, classRecords) in groupedRecords) { @@ -685,21 +725,28 @@ fun HealthConnectScreen( Log.d("SendData", "All posts successful. Saved token: ${pendingTokenToPersist?.take(10)}...") pendingTokenToPersist = null } else { - statusMessage = "Data sent successfully, but no new token was pending." + statusMessage = "Data sent successfully." Log.d("SendData", "All posts successful. No new token was pending to save.") } healthRecords = emptyList() pendingDeletionIds = emptyList() } else { - Log.w("SendData", "Not all posts successful. Pending records and their token candidate remain.") + Log.w("SendData", "Not all posts successful. Pending records remain.") } isProcessing = false } - + + /** Perform fetch + send in one step. */ + suspend fun syncNow(currentContext: Context) { + fetchHealthData(currentContext) + if (healthRecords.isNotEmpty() || pendingDeletionIds.isNotEmpty()) { + sendPendingDataToServer(currentContext) + } + } + LaunchedEffect(Unit) { - Log.d("HealthConnectScreen", "LaunchedEffect: Initial check - current status: $statusMessage, isProcessing: $isProcessing") + Log.d("HealthConnectScreen", "LaunchedEffect: Initial check") checkPermissionsAndFetchData(this, context) - // Re-schedule background sync if it was previously enabled if (backgroundSyncEnabled) { HealthConnectSyncWorker.schedule(context) } @@ -708,14 +755,13 @@ fun HealthConnectScreen( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - Log.d("HealthConnectScreen", "App resumed. HasPermissions: $hasPermissions, IsProcessing: $isProcessing, BackgroundSyncEnabled: $backgroundSyncEnabled") - if (hasPermissions && !isProcessing) { - Log.d("HealthConnectScreen", "Permissions granted and not processing, fetching data on resume.") + Log.d("HealthConnectScreen", "App resumed. HasAny: $hasAnyPermissions, IsProcessing: $isProcessing") + if (hasAnyPermissions && !isProcessing) { scope.launch { + // Re-check permissions in case user changed them in system settings + refreshPermissions() fetchHealthData(context) - // Auto-send when background sync is enabled if (backgroundSyncEnabled && (healthRecords.isNotEmpty() || pendingDeletionIds.isNotEmpty())) { - Log.d("HealthConnectScreen", "Background sync enabled, auto-sending ${healthRecords.size} records and ${pendingDeletionIds.size} deletions.") sendPendingDataToServer(context) } } @@ -729,208 +775,223 @@ fun HealthConnectScreen( } // Periodic sync while app is open (when background sync is enabled) - LaunchedEffect(backgroundSyncEnabled, hasPermissions) { - if (backgroundSyncEnabled && hasPermissions) { + LaunchedEffect(backgroundSyncEnabled, hasAnyPermissions) { + if (backgroundSyncEnabled && hasAnyPermissions) { Log.d("HealthConnectScreen", "Starting periodic sync loop (60s interval)") while (true) { - delay(60_000L) // Wait 60 seconds + delay(60_000L) if (!isProcessing) { Log.d("HealthConnectScreen", "Periodic sync: fetching and sending data") - fetchHealthData(context) - if (healthRecords.isNotEmpty() || pendingDeletionIds.isNotEmpty()) { - sendPendingDataToServer(context) - } + syncNow(context) } } } } - val supportedRecordsForDisplay by remember(healthRecords) { - derivedStateOf { - healthRecords.filterNot { record -> - val summary = getRecordSummary(record) - summary == record::class.simpleName || summary == "Record" - } - } - } - - val unsupportedRecordsSummaryText by remember(healthRecords) { - derivedStateOf { - val unsupported = healthRecords.filter { record -> - val summary = getRecordSummary(record) - summary == record::class.simpleName || summary == "Record" - } - val groupedByType = unsupported.groupBy { it::class } - if (groupedByType.isEmpty()) { - "" - } else { - val count = unsupported.size - val typesString = groupedByType.keys.mapNotNull { it.simpleName }.distinct().sorted().joinToString(", ") - "$count Unsupported Records of types: $typesString" - } - } + val pendingRecordCount by remember(healthRecords) { + derivedStateOf { healthRecords.size + pendingDeletionIds.size } } - val groupedAndSortedSupportedRecordsForDisplay by remember(supportedRecordsForDisplay) { - derivedStateOf { - val zoneId = ZoneId.systemDefault() - supportedRecordsForDisplay - .groupBy { record -> LocalDateTime.ofInstant(record.getPrimaryInstant(), zoneId).toLocalDate() } - .entries - .sortedByDescending { it.key } - .map { entry -> entry.key to entry.value.sortedByDescending { record -> record.getPrimaryInstant() } } - } - } + // --- UI --- - val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") } - val dateHeaderFormatter = remember { DateTimeFormatter.ofPattern("EEE, MMM d, yyyy") } - val today = remember { LocalDate.now(ZoneId.systemDefault()) } - val yesterday = remember { today.minusDays(1) } - - Column( + LazyColumn( modifier = modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(statusMessage) - Spacer(modifier = Modifier.height(8.dp)) - - if (!hasPermissions) { - Button( - onClick = { scope.launch { checkPermissionsAndFetchData(scope, context) } }, - enabled = !isProcessing + // -- Sync Status Card -- + item { + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth() ) { - Text("Request Permissions") - } - } else { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { scope.launch { fetchHealthData(context) } }, - enabled = !isProcessing + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Fetch New Data") - } - Button( - onClick = { scope.launch { sendPendingDataToServer(context) } }, - enabled = (pendingDeletionIds.isNotEmpty() || healthRecords.any { record -> - when (record) { - is HeartRateVariabilityRmssdRecord, is WeightRecord, is HeartRateRecord, - is ExerciseSessionRecord, is SpeedRecord, is PowerRecord, is NutritionRecord, - is LeanBodyMassRecord, is BodyFatRecord, is SleepSessionRecord, is BoneMassRecord, - is BodyWaterMassRecord, is HeightRecord, is RestingHeartRateRecord, - is StepsRecord, is DistanceRecord, is ActiveCaloriesBurnedRecord, - is TotalCaloriesBurnedRecord, is FloorsClimbedRecord -> true - else -> false + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Health Connect Sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + if (isProcessing) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.height(16.dp).width(16.dp), + strokeWidth = 2.dp + ) } - }) && !isProcessing - ) { - Text("Send Pending Data") + } + + Text( + statusMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + "${grantedRecordTypes.size} of ${allRecordTypes.size} data types authorized", + style = MaterialTheme.typography.bodyMedium + ) + + if (pendingRecordCount > 0) { + Text( + "$pendingRecordCount records pending", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Background Sync", style = MaterialTheme.typography.bodyMedium) + Switch( + checked = backgroundSyncEnabled, + onCheckedChange = { enabled -> + backgroundSyncEnabled = enabled + setBackgroundSyncEnabled(context, enabled) + if (enabled && !isIgnoringBatteryOptimizations(context)) { + showBatteryOptimizationDialog = true + } + } + ) + } + + Button( + onClick = { scope.launch { syncNow(context) } }, + enabled = hasAnyPermissions && !isProcessing, + modifier = Modifier.fillMaxWidth() + ) { + Text("Sync Now") + } } } + } - Spacer(modifier = Modifier.height(8.dp)) + // -- Data Source Category Cards -- + items(categoryStatuses.size) { index -> + val status = categoryStatuses[index] + val iconText = when { + status.allGranted -> "\u2705" // green check + status.partiallyGranted -> "\u26A0\uFE0F" // amber warning + else -> "\u274C" // red X + } + val iconColor = when { + status.allGranted -> MaterialTheme.colorScheme.primary + status.partiallyGranted -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth() ) { - Text("Background Sync") - Switch( - checked = backgroundSyncEnabled, - onCheckedChange = { enabled -> - backgroundSyncEnabled = enabled - setBackgroundSyncEnabled(context, enabled) - if (enabled && !isIgnoringBatteryOptimizations(context)) { - showBatteryOptimizationDialog = true - } - } - ) - } + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(iconText, style = MaterialTheme.typography.titleLarge) - if (showBatteryOptimizationDialog) { - AlertDialog( - onDismissRequest = { showBatteryOptimizationDialog = false }, - title = { Text("Battery Optimization") }, - text = { + Column(modifier = Modifier.weight(1f)) { Text( - "For reliable background sync, allow Aurboda to run " + - "without battery restrictions. This helps ensure your " + - "health data syncs even when the app is closed." + status.category.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold ) - }, - confirmButton = { - Button( + Text( + status.category.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (!status.allGranted) { + Text( + "${status.grantedCount}/${status.totalCount} types", + style = MaterialTheme.typography.bodySmall, + color = iconColor + ) + } + } + + if (!status.allGranted) { + androidx.compose.material3.OutlinedButton( onClick = { - showBatteryOptimizationDialog = false - batteryOptimizationLauncher.launch( - createBatteryOptimizationIntent(context) - ) + val categoryPermissions = status.category.recordTypes + .map { HealthPermission.getReadPermission(it) } + .toTypedArray() + requestPermissionLauncher.launch(categoryPermissions) } ) { - Text("Allow") - } - }, - dismissButton = { - Button( - onClick = { showBatteryOptimizationDialog = false } - ) { - Text("Not Now") + Text("Grant") } } - ) + } } } - Text("Total pending records (all types): ${healthRecords.size}") - if (unsupportedRecordsSummaryText.isNotEmpty()) { - Text(unsupportedRecordsSummaryText) + // -- Grant All Permissions button -- + if (!hasAllPermissions) { + item { + androidx.compose.material3.OutlinedButton( + onClick = { + requestPermissionLauncher.launch(allPermissions.toTypedArray()) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Grant All Permissions") + } + } } - Spacer(modifier = Modifier.height(8.dp)) - - if (groupedAndSortedSupportedRecordsForDisplay.isNotEmpty()) { - LazyColumn(modifier = Modifier.weight(1f)) { - groupedAndSortedSupportedRecordsForDisplay.forEach { (date, recordsInGroup) -> - item { - val dateHeaderText = when (date) { - today -> "Today" - yesterday -> "Yesterday" - else -> date.format(dateHeaderFormatter) - } - Text( - text = dateHeaderText, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp) + + // -- Empty state -- + if (!hasAnyPermissions && !isProcessing) { + item { + Text( + "Grant at least one data category to start syncing your health data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + } + } + + // Battery optimization dialog + if (showBatteryOptimizationDialog) { + AlertDialog( + onDismissRequest = { showBatteryOptimizationDialog = false }, + title = { Text("Battery Optimization") }, + text = { + Text( + "For reliable background sync, allow Aurboda to run " + + "without battery restrictions. This helps ensure your " + + "health data syncs even when the app is closed." + ) + }, + confirmButton = { + Button( + onClick = { + showBatteryOptimizationDialog = false + batteryOptimizationLauncher.launch( + createBatteryOptimizationIntent(context) ) } - items(recordsInGroup) { record -> - Row(verticalAlignment = Alignment.CenterVertically) { - val recordTime = LocalDateTime.ofInstant(record.getPrimaryInstant(), ZoneId.systemDefault()).format(timeFormatter) - val recordSummaryText = getRecordSummary(record) - Row { - recordTime.forEach { char -> - Text( - text = char.toString(), - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - modifier = Modifier.width(12.dp) - ) - } - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = recordSummaryText, - modifier = Modifier.weight(1f) - ) - } - } + ) { + Text("Allow") + } + }, + dismissButton = { + Button( + onClick = { showBatteryOptimizationDialog = false } + ) { + Text("Not Now") } } - } else if (hasPermissions && !isProcessing && healthRecords.isEmpty()) { - Text("No health records found for the selected period.") - } else if (hasPermissions && !isProcessing && supportedRecordsForDisplay.isEmpty() && healthRecords.isNotEmpty()){ - Text("No records with custom display found. Check 'Unsupported Records' summary above.") - } + ) } } diff --git a/apps/android/app/src/test/java/net/aurboda/HealthConnectPermissionsTest.kt b/apps/android/app/src/test/java/net/aurboda/HealthConnectPermissionsTest.kt new file mode 100644 index 0000000..4f66187 --- /dev/null +++ b/apps/android/app/src/test/java/net/aurboda/HealthConnectPermissionsTest.kt @@ -0,0 +1,146 @@ +package net.aurboda + +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.* +import org.junit.Test +import org.junit.Assert.* + +class HealthConnectPermissionsTest { + + @Test + fun `all allRecordTypes are covered by exactly one category`() { + val categorized = healthDataCategories.flatMap { it.recordTypes } + val categorizedSet = categorized.toSet() + + // Every allRecordType must appear in categories + for (recordType in allRecordTypes) { + assertTrue( + "${recordType.simpleName} is not in any category", + recordType in categorizedSet + ) + } + + // No duplicates across categories + assertEquals( + "Some record types appear in multiple categories", + categorized.size, + categorizedSet.size + ) + + // Every categorized type must be in allRecordTypes + val allRecordSet = allRecordTypes.toSet() + for (recordType in categorizedSet) { + assertTrue( + "${recordType.simpleName} is in categories but not in allRecordTypes", + recordType in allRecordSet + ) + } + } + + @Test + fun `getGrantedRecordTypes returns only types with granted read permission`() { + val subset = listOf(StepsRecord::class, WeightRecord::class, SleepSessionRecord::class) + val grantedPermissions = setOf( + HealthPermission.getReadPermission(StepsRecord::class), + HealthPermission.getReadPermission(SleepSessionRecord::class) + ) + + val result = getGrantedRecordTypes(grantedPermissions, subset) + + assertEquals(2, result.size) + assertTrue(StepsRecord::class in result) + assertTrue(SleepSessionRecord::class in result) + assertFalse(WeightRecord::class in result) + } + + @Test + fun `getGrantedRecordTypes returns empty list when no permissions granted`() { + val result = getGrantedRecordTypes(emptySet(), allRecordTypes) + assertTrue(result.isEmpty()) + } + + @Test + fun `getGrantedRecordTypes returns all types when all permissions granted`() { + val allPermissions = allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() + val result = getGrantedRecordTypes(allPermissions, allRecordTypes) + assertEquals(allRecordTypes.size, result.size) + } + + @Test + fun `getCategoryStatuses computes correct counts`() { + val grantedPermissions = setOf( + HealthPermission.getReadPermission(StepsRecord::class), + HealthPermission.getReadPermission(DistanceRecord::class), + HealthPermission.getReadPermission(SleepSessionRecord::class) + ) + + val statuses = getCategoryStatuses(grantedPermissions) + + // Activity category should be partially granted (2 of 12) + val activityStatus = statuses.first { it.category.name == "Activity & Exercise" } + assertEquals(2, activityStatus.grantedCount) + assertEquals(12, activityStatus.totalCount) + assertTrue(activityStatus.partiallyGranted) + assertFalse(activityStatus.allGranted) + assertFalse(activityStatus.noneGranted) + + // Sleep should be fully granted (1 of 1) + val sleepStatus = statuses.first { it.category.name == "Sleep" } + assertEquals(1, sleepStatus.grantedCount) + assertEquals(1, sleepStatus.totalCount) + assertTrue(sleepStatus.allGranted) + assertFalse(sleepStatus.partiallyGranted) + assertFalse(sleepStatus.noneGranted) + + // Heart should be none granted + val heartStatus = statuses.first { it.category.name == "Heart & Vitals" } + assertEquals(0, heartStatus.grantedCount) + assertTrue(heartStatus.noneGranted) + assertFalse(heartStatus.allGranted) + assertFalse(heartStatus.partiallyGranted) + } + + @Test + fun `getCategoryStatuses with all permissions shows all granted`() { + val allPermissions = allRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() + val statuses = getCategoryStatuses(allPermissions) + + for (status in statuses) { + assertTrue("${status.category.name} should be all granted", status.allGranted) + assertFalse(status.partiallyGranted) + assertFalse(status.noneGranted) + } + } + + @Test + fun `getCategoryStatuses with no permissions shows all none granted`() { + val statuses = getCategoryStatuses(emptySet()) + + for (status in statuses) { + assertTrue("${status.category.name} should be none granted", status.noneGranted) + assertFalse(status.allGranted) + assertFalse(status.partiallyGranted) + } + } + + @Test + fun `CategoryPermissionStatus boundary - single type category`() { + val singleCategory = HealthDataCategory( + name = "Test", + recordTypes = listOf(SleepSessionRecord::class), + description = "Test" + ) + + // None granted + val noneStatus = CategoryPermissionStatus(singleCategory, grantedCount = 0, totalCount = 1) + assertTrue(noneStatus.noneGranted) + assertFalse(noneStatus.allGranted) + assertFalse(noneStatus.partiallyGranted) + + // All granted + val allStatus = CategoryPermissionStatus(singleCategory, grantedCount = 1, totalCount = 1) + assertTrue(allStatus.allGranted) + assertFalse(noneStatus.partiallyGranted) + assertFalse(allStatus.noneGranted) + } +} diff --git a/apps/backend/src/db/connection.test.ts b/apps/backend/src/db/connection.test.ts new file mode 100644 index 0000000..c208b8e --- /dev/null +++ b/apps/backend/src/db/connection.test.ts @@ -0,0 +1,168 @@ +import type { Client, QueryResult, QueryResultRow } from 'pg' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { _isSchemaError, _runMigrationOnce, _setClientForUser, query } from './connection' + +describe('isSchemaError', () => { + test('returns true for undefined_table (42P01)', () => { + const error = Object.assign(new Error('relation "foo" does not exist'), { code: '42P01' }) + expect(_isSchemaError(error)).toBe(true) + }) + + test('returns true for undefined_column (42703)', () => { + const error = Object.assign(new Error('column "bar" does not exist'), { code: '42703' }) + expect(_isSchemaError(error)).toBe(true) + }) + + test('returns false for syntax error', () => { + const error = Object.assign(new Error('syntax error'), { code: '42601' }) + expect(_isSchemaError(error)).toBe(false) + }) + + test('returns false for unique violation', () => { + const error = Object.assign(new Error('duplicate key'), { code: '23505' }) + expect(_isSchemaError(error)).toBe(false) + }) + + test('returns false for error without code', () => { + expect(_isSchemaError(new Error('some error'))).toBe(false) + }) + + test('returns false for non-Error values', () => { + expect(_isSchemaError('string error')).toBe(false) + expect(_isSchemaError(null)).toBe(false) + expect(_isSchemaError(undefined)).toBe(false) + }) +}) + +describe('runMigrationOnce', () => { + const mockMigrate = vi.fn<(user: string) => Promise>() + + beforeEach(() => { + mockMigrate.mockReset() + }) + + test('calls migrateSchema for the user', async () => { + mockMigrate.mockResolvedValue(undefined) + await _runMigrationOnce('testuser', mockMigrate) + expect(mockMigrate).toHaveBeenCalledWith('testuser') + expect(mockMigrate).toHaveBeenCalledTimes(1) + }) + + test('coalesces concurrent calls for same user', async () => { + let resolveFirst!: () => void + mockMigrate.mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + + const promise1 = _runMigrationOnce('user1', mockMigrate) + const promise2 = _runMigrationOnce('user1', mockMigrate) + + // Both should return the same promise + expect(promise1).toBe(promise2) + + resolveFirst() + await promise1 + await promise2 + + // Only called once despite two requests + expect(mockMigrate).toHaveBeenCalledTimes(1) + }) + + test('does not coalesce calls for different users', async () => { + mockMigrate.mockResolvedValue(undefined) + + const promise1 = _runMigrationOnce('user1', mockMigrate) + const promise2 = _runMigrationOnce('user2', mockMigrate) + + expect(promise1).not.toBe(promise2) + + await promise1 + await promise2 + + expect(mockMigrate).toHaveBeenCalledTimes(2) + }) + + test('releases lock on failure, allowing subsequent calls', async () => { + mockMigrate.mockRejectedValueOnce(new Error('migration failed')) + + await expect(_runMigrationOnce('failuser', mockMigrate)).rejects.toThrow('migration failed') + + // After failure, a new call should trigger a new migration attempt + mockMigrate.mockResolvedValue(undefined) + await _runMigrationOnce('failuser', mockMigrate) + expect(mockMigrate).toHaveBeenCalledTimes(2) + }) +}) + +const mockQueryResult = (rows: T[] = []): QueryResult => ({ + command: 'SELECT', + fields: [], + oid: 0, + rowCount: rows.length, + rows, +}) + +describe('query retry on schema error', () => { + const makeClient = (queryFn: (...args: unknown[]) => Promise) => + ({ query: queryFn }) as unknown as Client + + test('retries on schema error when called with username', async () => { + const mockMigrate = vi.fn<(user: string) => Promise>().mockResolvedValue(undefined) + let callCount = 0 + const client = makeClient(async () => { + callCount++ + if (callCount === 1) { + throw Object.assign(new Error('relation "metrics" does not exist'), { code: '42P01' }) + } + return mockQueryResult([{ id: 1 }]) + }) + + _setClientForUser('retryuser', client) + const result = await query('retryuser', 'SELECT * FROM metrics', undefined, mockMigrate) + + expect(result.rows).toEqual([{ id: 1 }]) + expect(mockMigrate).toHaveBeenCalledWith('retryuser') + expect(callCount).toBe(2) + }) + + test('does NOT retry when called with Client directly', async () => { + const mockMigrate = vi.fn<(user: string) => Promise>() + const client = makeClient(async () => { + throw Object.assign(new Error('relation "metrics" does not exist'), { code: '42P01' }) + }) + + await expect(query(client, 'SELECT * FROM metrics', undefined, mockMigrate)).rejects.toThrow( + 'relation "metrics" does not exist', + ) + expect(mockMigrate).not.toHaveBeenCalled() + }) + + test('does NOT retry on non-schema errors', async () => { + const mockMigrate = vi.fn<(user: string) => Promise>() + const client = makeClient(async () => { + throw Object.assign(new Error('syntax error'), { code: '42601' }) + }) + + _setClientForUser('syntaxuser', client) + await expect(query('syntaxuser', 'SELECT * FROM foo', undefined, mockMigrate)).rejects.toThrow( + 'syntax error', + ) + expect(mockMigrate).not.toHaveBeenCalled() + }) + + test('propagates error if retry also fails', async () => { + const mockMigrate = vi.fn<(user: string) => Promise>().mockResolvedValue(undefined) + const client = makeClient(async () => { + throw Object.assign(new Error('relation "metrics" does not exist'), { code: '42P01' }) + }) + + _setClientForUser('doublefail', client) + await expect(query('doublefail', 'SELECT * FROM metrics', undefined, mockMigrate)).rejects.toThrow( + 'relation "metrics" does not exist', + ) + expect(mockMigrate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/backend/src/db/connection.ts b/apps/backend/src/db/connection.ts index f81dcc3..4d6b7f0 100644 --- a/apps/backend/src/db/connection.ts +++ b/apps/backend/src/db/connection.ts @@ -17,14 +17,58 @@ export const _setClientForUser = (user: string, client: Client) => { dbByUser[user] = client } +/** + * Check if an error is a PostgreSQL schema error (missing table or column). + * Only these errors should trigger automatic migration retry. + * @internal Exported for testing. + */ +export const _isSchemaError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false + const code = (error as Error & { code?: string }).code + return code === '42P01' || code === '42703' // undefined_table, undefined_column +} + +const migrationInProgress: Record | undefined> = {} + +/** + * Run migration for a user, coalescing concurrent calls. + * If a migration is already in progress for the user, returns the existing promise. + * @internal Exported for testing — pass a custom migrate function in tests. + */ +export const _runMigrationOnce = ( + user: string, + migrate: (user: string) => Promise = migrateSchema, +): Promise => { + const existing = migrationInProgress[user] + if (existing) return existing + + const promise = migrate(user).finally(() => { + delete migrationInProgress[user] + }) + migrationInProgress[user] = promise + return promise +} + export const query = async ( dbOrUser: Client | string, queryStr: string, params?: unknown[], + /** @internal Override migration function for testing. */ + migrate?: (user: string) => Promise, ) => { const db = typeof dbOrUser === 'string' ? await getDbForUser(dbOrUser) : dbOrUser - const result = await db.query(queryStr, params) - return result + + try { + return await db.query(queryStr, params) + } catch (error) { + // Only retry with migration when called with a username (not a Client directly) + if (typeof dbOrUser === 'string' && _isSchemaError(error)) { + console.log(`Schema error for user ${dbOrUser}, running migration and retrying: ${error}`) + await _runMigrationOnce(dbOrUser, migrate) + return await db.query(queryStr, params) + } + throw error + } } export const loginToUserDb = async (user: string, password: string) => {