From 50166ee92541f4452a2e9cbffdb038c3f96b04c1 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 00:00:24 +0300 Subject: [PATCH 01/19] fix: stabilize map location and loading behavior --- .../presentation/GatheringAreasScreen.kt | 18 +++++++----- .../presentation/HelpRequestMapScreen.kt | 17 ++++++----- .../feature/location/LocationPicker.tsx | 28 +++++++++++-------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt b/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt index ef6a10f7..2f414d6d 100644 --- a/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt +++ b/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt @@ -396,6 +396,7 @@ fun GatheringAreasScreen( activeFilterKeys.contains(item.category.trim().lowercase()) }.orEmpty() val isFilterEmpty = currentResult != null && currentResult.areas.isNotEmpty() && visibleAreas.isEmpty() + val mapActionsEnabled = !loading && !backgroundUpdating if (selectedAreaId != null && visibleAreas.none { it.id == selectedAreaId }) { selectedAreaId = null @@ -456,7 +457,7 @@ fun GatheringAreasScreen( pendingViewport = viewport viewportRefreshNonce += 1 }, - enabled = !loading + enabled = mapActionsEnabled ) } } @@ -477,7 +478,7 @@ fun GatheringAreasScreen( loadingResources = loading, updatingResources = backgroundUpdating, onShowCurrentLocation = ::showCurrentLocationOnMap, - showCurrentLocationEnabled = !loading, + showCurrentLocationEnabled = mapActionsEnabled, onViewportChanged = ::handleViewportChanged ) @@ -499,7 +500,8 @@ fun GatheringAreasScreen( onClick = { pendingViewport = currentViewport viewportRefreshNonce += 1 - } + }, + enabled = mapActionsEnabled ) } } @@ -519,7 +521,8 @@ fun GatheringAreasScreen( onClick = { pendingViewport = currentViewport viewportRefreshNonce += 1 - } + }, + enabled = mapActionsEnabled ) } } @@ -663,7 +666,8 @@ fun GatheringAreasScreen( onClick = { pendingViewport = currentViewport viewportRefreshNonce += 1 - } + }, + enabled = mapActionsEnabled ) } } @@ -851,11 +855,11 @@ private fun GatheringAreasMapCard( HelperText(text = ResourceLoadingMessage) } - if (updatingResources && markers.isNotEmpty()) { + if (updatingResources) { HelperText(text = ResourceUpdatingMessage) } - if (markers.isEmpty() && !loadingResources) { + if (markers.isEmpty() && !loadingResources && !updatingResources) { HelperText( text = emptyMarkersMessage ) diff --git a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt index e2c70950..f7b9a248 100644 --- a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt +++ b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt @@ -475,6 +475,7 @@ fun HelpRequestMapScreen( val selectedRequest = visibleRequests.firstOrNull { it.requestId == selectedRequestId } val isFilterEmpty = !loading && requests.isNotEmpty() && visibleRequests.isEmpty() + val mapActionsEnabled = !loading && !backgroundUpdating val mapEmptyMarkersMessage = helpRequestMapEmptyMessage( blockingLoading = loading, errorMessage = errorMessage, @@ -518,7 +519,7 @@ fun HelpRequestMapScreen( SecondaryButton( text = "Refresh Help Request Map", onClick = { queueViewportRefresh() }, - enabled = !loading + enabled = mapActionsEnabled ) } } @@ -535,7 +536,7 @@ fun HelpRequestMapScreen( mapZoom = mapZoom, mapResetToken = mapResetNonce, onShowCurrentLocation = ::showCurrentLocationOnMap, - showCurrentLocationEnabled = !loading, + showCurrentLocationEnabled = mapActionsEnabled, onViewportChanged = ::handleViewportChanged, onSelectRequest = { selectedRequestId = it } ) @@ -557,7 +558,8 @@ fun HelpRequestMapScreen( ) SecondaryButton( text = "Retry", - onClick = { queueViewportRefresh() } + onClick = { queueViewportRefresh() }, + enabled = mapActionsEnabled ) } } @@ -643,7 +645,8 @@ fun HelpRequestMapScreen( HelperText(text = errorMessage) SecondaryButton( text = "Retry", - onClick = { queueViewportRefresh() } + onClick = { queueViewportRefresh() }, + enabled = mapActionsEnabled ) } } @@ -920,7 +923,7 @@ private fun CrisisRequestMapPanel( val effectiveCenterLatitude = if (shouldFitRequestMarkers) mapInstanceKey.centerLatitude else mapCenterLatitude val effectiveCenterLongitude = if (shouldFitRequestMarkers) mapInstanceKey.centerLongitude else mapCenterLongitude val effectiveZoom = if (shouldFitRequestMarkers) 13 else mapZoom - val mapInstanceId = remember(mapResetToken, mapInstanceKey) { + val mapInstanceId = remember(mapResetToken) { newLeafletMapInstanceId() } val currentMapInstanceIdState = remember { mutableStateOf(mapInstanceId) } @@ -1052,11 +1055,11 @@ private fun CrisisRequestMapPanel( HelperText(text = ResourceLoadingMessage) } - if (updatingResources && markers.isNotEmpty()) { + if (updatingResources) { HelperText(text = ResourceUpdatingMessage) } - if (markers.isEmpty() && !loadingResources) { + if (markers.isEmpty() && !loadingResources && !updatingResources) { HelperText(text = emptyMarkersMessage) } diff --git a/web/src/components/feature/location/LocationPicker.tsx b/web/src/components/feature/location/LocationPicker.tsx index 0050b8f9..3fbdb391 100644 --- a/web/src/components/feature/location/LocationPicker.tsx +++ b/web/src/components/feature/location/LocationPicker.tsx @@ -31,15 +31,24 @@ const DEFAULT_CENTER = { const DEFAULT_MAP_ZOOM = 6; const SELECTED_LOCATION_ZOOM = 15; -function toPickerValue(item: LocationSearchItem): LocationPickerValue { +function toReverseEnrichedPickerValue( + latitude: number, + longitude: number, + item: LocationSearchItem, + metadata?: { + source?: string | null; + accuracyMeters?: number | null; + capturedAt?: string | null; + } +): LocationPickerValue { return { placeId: item.placeId, displayName: item.displayName, - latitude: item.latitude, - longitude: item.longitude, - accuracyMeters: null, - source: "search", - capturedAt: new Date().toISOString(), + latitude, + longitude, + accuracyMeters: metadata?.accuracyMeters ?? null, + source: metadata?.source ?? "map_pin", + capturedAt: metadata?.capturedAt ?? new Date().toISOString(), administrative: item.administrative, }; } @@ -126,12 +135,7 @@ export function LocationPicker({ return; } - onChange({ - ...toPickerValue(response.item), - source: metadata?.source ?? "map_pin", - accuracyMeters: metadata?.accuracyMeters ?? null, - capturedAt: metadata?.capturedAt ?? new Date().toISOString(), - }); + onChange(toReverseEnrichedPickerValue(latitude, longitude, response.item, metadata)); } catch (err) { if (currentReverseRequestId !== reverseRequestIdRef.current) { return; From c09f080f8f6e9fedac5eed9d991f431288362f29 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 00:25:59 +0300 Subject: [PATCH 02/19] fix: preserve initial help map marker fit --- .../presentation/HelpRequestMapScreen.kt | 14 ++++++++ .../java/com/neph/ui/map/LeafletMapWebView.kt | 33 ++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt index f7b9a248..b53d7163 100644 --- a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt +++ b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt @@ -246,8 +246,18 @@ fun HelpRequestMapScreen( var mapCenterLongitude by remember { mutableStateOf(TurkeyOverviewLongitude) } var mapZoom by remember { mutableStateOf(TurkeyOverviewZoom) } var mapResetNonce by remember { mutableStateOf(0) } + var initialMarkerFitApplied by remember { mutableStateOf(false) } + var markerFitBoundsToken by remember { mutableStateOf(null) } fun applyRequestResult(result: ActiveHelpRequestsResult, viewportKey: String?) { + if ( + !initialMarkerFitApplied && + viewportKey == null && + result.requests.any { it.hasValidMapCoordinates() } + ) { + markerFitBoundsToken = (markerFitBoundsToken ?: 0) + 1 + initialMarkerFitApplied = true + } requests = result.requests viewportKey?.let { lastFetchedViewportKey = it } viewportRefreshNonce = 0 @@ -300,6 +310,7 @@ fun HelpRequestMapScreen( mapCenterLongitude = location.longitude mapZoom = HelpRequestMapCurrentLocationZoom mapResetNonce += 1 + markerFitBoundsToken = null selectedRequestId = null lastFetchedViewportKey = null loading = false @@ -535,6 +546,7 @@ fun HelpRequestMapScreen( mapCenterLongitude = mapCenterLongitude, mapZoom = mapZoom, mapResetToken = mapResetNonce, + fitBoundsRequestToken = markerFitBoundsToken, onShowCurrentLocation = ::showCurrentLocationOnMap, showCurrentLocationEnabled = mapActionsEnabled, onViewportChanged = ::handleViewportChanged, @@ -908,6 +920,7 @@ private fun CrisisRequestMapPanel( mapCenterLongitude: Double = TurkeyOverviewLongitude, mapZoom: Int = TurkeyOverviewZoom, mapResetToken: Int = 0, + fitBoundsRequestToken: Int? = null, onShowCurrentLocation: (() -> Unit)? = null, showCurrentLocationEnabled: Boolean = true, onViewportChanged: (LeafletMapViewport) -> Unit, @@ -1002,6 +1015,7 @@ private fun CrisisRequestMapPanel( zoom = effectiveZoom, showCenterMarker = false, fitBoundsToMarkers = shouldFitRequestMarkers, + fitBoundsRequestToken = fitBoundsRequestToken, onMarkerSelected = { markerInstanceId, markerId -> if (markerInstanceId == currentMapInstanceIdState.value) { onSelectRequest(markerId) diff --git a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt index af0a514a..31255eda 100644 --- a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt +++ b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt @@ -292,6 +292,7 @@ fun LeafletMarkerMap( zoom: Int = 13, showCenterMarker: Boolean = true, fitBoundsToMarkers: Boolean = true, + fitBoundsRequestToken: Int? = null, onMarkerSelected: (String, String) -> Unit, onMapReady: (String, String) -> Unit, onMapError: (String, String) -> Unit, @@ -345,17 +346,26 @@ fun LeafletMarkerMap( html = html, bridgeName = LeafletMarkerMapBridgeName, bridge = bridge, - javaScriptUpdate = buildMarkerMapUpdateScript(markers, selectedMarkerId), + javaScriptUpdate = buildMarkerMapUpdateScript( + markers = markers, + selectedMarkerId = selectedMarkerId, + fitBoundsRequestToken = fitBoundsRequestToken + ), modifier = modifier ) } -private fun buildMarkerMapUpdateScript(markers: List, selectedMarkerId: String?): String { +private fun buildMarkerMapUpdateScript( + markers: List, + selectedMarkerId: String?, + fitBoundsRequestToken: Int? +): String { val markersJson = leafletMarkersJson(markers) val selectedMarkerJson = selectedMarkerId?.let(JSONObject::quote) ?: "null" + val fitBoundsRequestTokenJson = fitBoundsRequestToken?.toString() ?: "null" return """ if (window.nephSetMarkers) { - window.nephSetMarkers($markersJson, $selectedMarkerJson); + window.nephSetMarkers($markersJson, $selectedMarkerJson, $fitBoundsRequestTokenJson); } else if (window.nephSelectMarker) { window.nephSelectMarker($selectedMarkerJson); } @@ -576,6 +586,7 @@ internal fun buildLeafletMarkerMapHtml( var markerData = $markersJson; var selectedMarkerId = $selectedMarkerJson; var fitBoundsToMarkers = $fitBoundsToMarkersJson; + var lastAppliedFitBoundsRequestToken = null; var mapElement = document.getElementById('map'); if (!mapElement) { failMap('Map failed to load.'); @@ -644,7 +655,7 @@ internal fun buildLeafletMarkerMapHtml( }); }; - window.nephSetMarkers = function(nextMarkers, nextSelectedMarkerId) { + window.nephSetMarkers = function(nextMarkers, nextSelectedMarkerId, fitBoundsRequestToken) { markerData = Array.isArray(nextMarkers) ? nextMarkers : []; selectedMarkerId = nextSelectedMarkerId || null; Object.keys(areaMarkersById).forEach(function(id) { @@ -675,6 +686,20 @@ internal fun buildLeafletMarkerMapHtml( }; bounds.push([marker.latitude, marker.longitude]); }); + + var normalizedFitBoundsRequestToken = + fitBoundsRequestToken === null || typeof fitBoundsRequestToken === 'undefined' + ? null + : String(fitBoundsRequestToken); + if ( + normalizedFitBoundsRequestToken !== null && + normalizedFitBoundsRequestToken !== lastAppliedFitBoundsRequestToken + ) { + if (bounds.length > 1) { + map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); + } + lastAppliedFitBoundsRequestToken = normalizedFitBoundsRequestToken; + } }; window.nephSetMarkers(markerData, selectedMarkerId); From 1137ea7ea241ff547923d9b287213ebf75510792 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 00:31:08 +0300 Subject: [PATCH 03/19] fix: fit initial help markers without stale center --- .../src/main/java/com/neph/ui/map/LeafletMapWebView.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt index 31255eda..a7895bf1 100644 --- a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt +++ b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt @@ -663,6 +663,7 @@ internal fun buildLeafletMarkerMapHtml( }); areaMarkersById = {}; bounds = [center]; + var markerBounds = []; markerData.forEach(function(marker) { var selected = marker.id === selectedMarkerId; @@ -685,6 +686,7 @@ internal fun buildLeafletMarkerMapHtml( data: marker }; bounds.push([marker.latitude, marker.longitude]); + markerBounds.push([marker.latitude, marker.longitude]); }); var normalizedFitBoundsRequestToken = @@ -695,8 +697,10 @@ internal fun buildLeafletMarkerMapHtml( normalizedFitBoundsRequestToken !== null && normalizedFitBoundsRequestToken !== lastAppliedFitBoundsRequestToken ) { - if (bounds.length > 1) { - map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); + if (markerBounds.length > 1) { + map.fitBounds(markerBounds, { padding: [24, 24], maxZoom: 15 }); + } else if (markerBounds.length === 1) { + map.setView(markerBounds[0], 15); } lastAppliedFitBoundsRequestToken = normalizedFitBoundsRequestToken; } From 301f71a8be6d462b310380b5f674eddc7a0e7e6d Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 01:25:04 +0300 Subject: [PATCH 04/19] feat: add bogazici final seed demo data --- ..._120000__seed_bogazici_final_demo_data.sql | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql diff --git a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql new file mode 100644 index 00000000..65374574 --- /dev/null +++ b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql @@ -0,0 +1,442 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM users + WHERE user_id LIKE 'demo_bogazici\_%' ESCAPE '\' + AND email NOT LIKE '%@neph.test' + ) THEN + RAISE EXCEPTION 'Refusing to apply Bogazici demo seed: a demo_bogazici_* user id exists with a non-demo email'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM help_requests + WHERE request_id LIKE 'demo_bogazici\_%' ESCAPE '\' + AND description NOT LIKE '[DEMO]%' + ) THEN + RAISE EXCEPTION 'Refusing to apply Bogazici demo seed: a demo_bogazici_* help request exists without demo ownership markers'; + END IF; +END $$; + +INSERT INTO users ( + user_id, + email, + password_hash, + is_email_verified, + accepted_terms, + is_deleted, + is_banned +) +VALUES + ('demo_bogazici_requester_new_hall', 'bogazici_requester_new_hall@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_requester_hisarustu', 'bogazici_requester_hisarustu@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_requester_rumeli', 'bogazici_requester_rumeli@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_requester_bebek', 'bogazici_requester_bebek@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_requester_etiler', 'bogazici_requester_etiler@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_requester_ucaksavar', 'bogazici_requester_ucaksavar@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_reserve_1', 'bogazici_reserve_1@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_reserve_2', 'bogazici_reserve_2@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_reserve_3', 'bogazici_reserve_3@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_reserve_4', 'bogazici_reserve_4@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_assigned_1', 'bogazici_assigned_1@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_assigned_2', 'bogazici_assigned_2@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_outer_1', 'bogazici_outer_1@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE), + ('demo_bogazici_user_outer_2', 'bogazici_outer_2@neph.test', '$2b$10$jVf8XVq2zkXAelSygXMVdO7IhqK1jjL4a4KtJw8iyttPBZLVi7USG', TRUE, TRUE, FALSE, FALSE) +ON CONFLICT (user_id) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + is_email_verified = TRUE, + accepted_terms = TRUE, + is_deleted = FALSE, + is_banned = FALSE, + ban_reason = NULL, + banned_at = NULL; + +INSERT INTO user_profiles ( + profile_id, + user_id, + first_name, + last_name, + phone_number +) +VALUES + ('demo_bogazici_profile_requester_new_hall', 'demo_bogazici_requester_new_hall', 'Selin', 'Arikan', '5328101101'), + ('demo_bogazici_profile_requester_hisarustu', 'demo_bogazici_requester_hisarustu', 'Murat', 'Erdem', '5328101102'), + ('demo_bogazici_profile_requester_rumeli', 'demo_bogazici_requester_rumeli', 'Nihan', 'Turan', '5328101103'), + ('demo_bogazici_profile_requester_bebek', 'demo_bogazici_requester_bebek', 'Ozan', 'Yalcin', '5328101104'), + ('demo_bogazici_profile_requester_etiler', 'demo_bogazici_requester_etiler', 'Aylin', 'Deniz', '5328101105'), + ('demo_bogazici_profile_requester_ucaksavar', 'demo_bogazici_requester_ucaksavar', 'Kerem', 'Soyer', '5328101106'), + ('demo_bogazici_profile_reserve_1', 'demo_bogazici_user_reserve_1', 'Ece', 'Kaya', '5328101201'), + ('demo_bogazici_profile_reserve_2', 'demo_bogazici_user_reserve_2', 'Arda', 'Yildirim', '5328101202'), + ('demo_bogazici_profile_reserve_3', 'demo_bogazici_user_reserve_3', 'Derya', 'Celik', '5328101203'), + ('demo_bogazici_profile_reserve_4', 'demo_bogazici_user_reserve_4', 'Berk', 'Acar', '5328101204'), + ('demo_bogazici_profile_assigned_1', 'demo_bogazici_user_assigned_1', 'Ipek', 'Ozturk', '5328101301'), + ('demo_bogazici_profile_assigned_2', 'demo_bogazici_user_assigned_2', 'Emir', 'Polat', '5328101302'), + ('demo_bogazici_profile_outer_1', 'demo_bogazici_user_outer_1', 'Cemre', 'Korkmaz', '5328101401'), + ('demo_bogazici_profile_outer_2', 'demo_bogazici_user_outer_2', 'Tolga', 'Sahin', '5328101402') +ON CONFLICT (profile_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + phone_number = EXCLUDED.phone_number; + +INSERT INTO physical_info ( + physical_id, + profile_id, + age, + date_of_birth, + gender, + height, + weight +) +VALUES + ('demo_bogazici_physical_requester_new_hall', 'demo_bogazici_profile_requester_new_hall', 22, DATE '2004-04-12', 'female', 166, 58), + ('demo_bogazici_physical_requester_hisarustu', 'demo_bogazici_profile_requester_hisarustu', 41, DATE '1985-09-03', 'male', 178, 82), + ('demo_bogazici_physical_requester_rumeli', 'demo_bogazici_profile_requester_rumeli', 34, DATE '1992-01-21', 'female', 164, 62), + ('demo_bogazici_physical_requester_bebek', 'demo_bogazici_profile_requester_bebek', 57, DATE '1969-06-18', 'male', 173, 76), + ('demo_bogazici_physical_requester_etiler', 'demo_bogazici_profile_requester_etiler', 68, DATE '1958-12-09', 'female', 160, 67), + ('demo_bogazici_physical_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', 29, DATE '1997-03-27', 'male', 181, 79), + ('demo_bogazici_physical_reserve_1', 'demo_bogazici_profile_reserve_1', 31, DATE '1995-08-14', 'female', 169, 61), + ('demo_bogazici_physical_reserve_2', 'demo_bogazici_profile_reserve_2', 35, DATE '1991-02-05', 'male', 182, 84), + ('demo_bogazici_physical_reserve_3', 'demo_bogazici_profile_reserve_3', 28, DATE '1998-07-30', 'female', 171, 64), + ('demo_bogazici_physical_reserve_4', 'demo_bogazici_profile_reserve_4', 39, DATE '1987-11-16', 'male', 176, 78), + ('demo_bogazici_physical_assigned_1', 'demo_bogazici_profile_assigned_1', 33, DATE '1993-05-22', 'female', 167, 60), + ('demo_bogazici_physical_assigned_2', 'demo_bogazici_profile_assigned_2', 44, DATE '1982-10-10', 'male', 180, 83), + ('demo_bogazici_physical_outer_1', 'demo_bogazici_profile_outer_1', 30, DATE '1996-09-01', 'female', 165, 59), + ('demo_bogazici_physical_outer_2', 'demo_bogazici_profile_outer_2', 46, DATE '1980-01-13', 'male', 177, 81) +ON CONFLICT (physical_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + age = EXCLUDED.age, + date_of_birth = EXCLUDED.date_of_birth, + gender = EXCLUDED.gender, + height = EXCLUDED.height, + weight = EXCLUDED.weight; + +INSERT INTO health_info ( + health_id, + profile_id, + medical_conditions, + chronic_diseases, + allergies, + medications, + blood_type +) +VALUES + ('demo_bogazici_health_requester_new_hall', 'demo_bogazici_profile_requester_new_hall', ARRAY['minor cut'], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'A+'), + ('demo_bogazici_health_requester_hisarustu', 'demo_bogazici_profile_requester_hisarustu', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY['dust'], ARRAY[]::TEXT[], '0+'), + ('demo_bogazici_health_requester_rumeli', 'demo_bogazici_profile_requester_rumeli', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'B+'), + ('demo_bogazici_health_requester_bebek', 'demo_bogazici_profile_requester_bebek', ARRAY['diabetes'], ARRAY['type 2 diabetes'], ARRAY[]::TEXT[], ARRAY['insulin'], 'AB+'), + ('demo_bogazici_health_requester_etiler', 'demo_bogazici_profile_requester_etiler', ARRAY['hypertension'], ARRAY['high blood pressure'], ARRAY['penicillin'], ARRAY['blood pressure medication'], 'A-'), + ('demo_bogazici_health_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], '0-'), + ('demo_bogazici_health_reserve_1', 'demo_bogazici_profile_reserve_1', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'A+'), + ('demo_bogazici_health_reserve_2', 'demo_bogazici_profile_reserve_2', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'B-'), + ('demo_bogazici_health_reserve_3', 'demo_bogazici_profile_reserve_3', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY['pollen'], ARRAY[]::TEXT[], '0+'), + ('demo_bogazici_health_reserve_4', 'demo_bogazici_profile_reserve_4', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'AB-'), + ('demo_bogazici_health_assigned_1', 'demo_bogazici_profile_assigned_1', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'B+'), + ('demo_bogazici_health_assigned_2', 'demo_bogazici_profile_assigned_2', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], 'A+'), + ('demo_bogazici_health_outer_1', 'demo_bogazici_profile_outer_1', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY[]::TEXT[], '0+'), + ('demo_bogazici_health_outer_2', 'demo_bogazici_profile_outer_2', ARRAY[]::TEXT[], ARRAY[]::TEXT[], ARRAY['dust'], ARRAY[]::TEXT[], 'A-') +ON CONFLICT (health_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + medical_conditions = EXCLUDED.medical_conditions, + chronic_diseases = EXCLUDED.chronic_diseases, + allergies = EXCLUDED.allergies, + medications = EXCLUDED.medications, + blood_type = EXCLUDED.blood_type; + +INSERT INTO location_profiles ( + location_profile_id, + profile_id, + address, + display_address, + city, + country, + country_code, + district, + neighborhood, + extra_address, + latitude, + longitude, + coordinate_accuracy_meters, + coordinate_source, + coordinate_captured_at +) +VALUES + ('demo_bogazici_location_profile_requester_new_hall', 'demo_bogazici_profile_requester_new_hall', 'Bogazici University North Campus, New Hall', 'New Hall, North Campus, Bogazici University, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'North Campus New Hall entrance', 41.08570, 29.04410, 18, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '28 minutes'), + ('demo_bogazici_location_profile_requester_hisarustu', 'demo_bogazici_profile_requester_hisarustu', 'Hisarustu, Nispetiye Caddesi', 'Hisarustu near North Campus gate, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Hisarustu', 'Nispetiye Caddesi campus gate area', 41.08850, 29.04160, 22, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '24 minutes'), + ('demo_bogazici_location_profile_requester_rumeli', 'demo_bogazici_profile_requester_rumeli', 'Rumeli Hisari, Yahya Kemal Caddesi', 'Rumeli Hisari, Yahya Kemal Caddesi, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Yahya Kemal Caddesi lower slope', 41.08420, 29.05670, 24, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '22 minutes'), + ('demo_bogazici_location_profile_requester_bebek', 'demo_bogazici_profile_requester_bebek', 'Bebek, Cevdet Pasa Caddesi', 'Bebek near Cevdet Pasa Caddesi, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Bebek', 'Cevdet Pasa Caddesi upper side street', 41.07400, 29.04390, 25, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '20 minutes'), + ('demo_bogazici_location_profile_requester_etiler', 'demo_bogazici_profile_requester_etiler', 'Etiler, Nispetiye Caddesi', 'Nispetiye Caddesi, Etiler, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Nispetiye Caddesi side street', 41.07460, 29.03310, 28, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '18 minutes'), + ('demo_bogazici_location_profile_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', 'Ucaksavar Kampusu yolu', 'Ucaksavar area above Hisarustu, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09120, 29.03480, 30, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '16 minutes'), + ('demo_bogazici_location_profile_reserve_1', 'demo_bogazici_profile_reserve_1', 'Bogazici North Campus south walkway', 'North Campus south walkway, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'South walkway below New Hall', 41.08455, 29.04320, 14, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '6 minutes'), + ('demo_bogazici_location_profile_reserve_2', 'demo_bogazici_profile_reserve_2', 'Bogazici North Campus library side', 'North Campus library side, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Library side path near New Hall', 41.08720, 29.04365, 14, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '7 minutes'), + ('demo_bogazici_location_profile_reserve_3', 'demo_bogazici_profile_reserve_3', 'North Campus dining hall service point', 'North Campus dining hall service point, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Student dining hall supply point', 41.08390, 29.04190, 16, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '8 minutes'), + ('demo_bogazici_location_profile_reserve_4', 'demo_bogazici_profile_reserve_4', 'Hisarustu upper street volunteer point', 'Hisarustu upper volunteer point, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Hisarustu', 'Upper Hisarustu street west of campus', 41.09020, 29.03980, 18, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '9 minutes'), + ('demo_bogazici_location_profile_assigned_1', 'demo_bogazici_profile_assigned_1', 'New Hall triage point', 'New Hall triage point, North Campus, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'New Hall triage point', 41.08585, 29.04435, 12, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '11 minutes'), + ('demo_bogazici_location_profile_assigned_2', 'demo_bogazici_profile_assigned_2', 'Hisarustu campus gate logistics point', 'Hisarustu campus gate logistics point, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Hisarustu', 'Campus gate logistics point', 41.08850, 29.04160, 15, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '12 minutes'), + ('demo_bogazici_location_profile_outer_1', 'demo_bogazici_profile_outer_1', 'Bebek volunteer checkpoint', 'Bebek volunteer checkpoint, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Bebek', 'Cevdet Pasa Caddesi volunteer checkpoint', 41.07400, 29.04390, 20, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '10 minutes'), + ('demo_bogazici_location_profile_outer_2', 'demo_bogazici_profile_outer_2', 'Etiler Nispetiye volunteer checkpoint', 'Etiler Nispetiye volunteer checkpoint, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Nispetiye Caddesi volunteer checkpoint', 41.07490, 29.03320, 22, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '10 minutes') +ON CONFLICT (location_profile_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + address = EXCLUDED.address, + display_address = EXCLUDED.display_address, + city = EXCLUDED.city, + country = EXCLUDED.country, + country_code = EXCLUDED.country_code, + district = EXCLUDED.district, + neighborhood = EXCLUDED.neighborhood, + extra_address = EXCLUDED.extra_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + coordinate_accuracy_meters = EXCLUDED.coordinate_accuracy_meters, + coordinate_source = EXCLUDED.coordinate_source, + coordinate_captured_at = EXCLUDED.coordinate_captured_at, + last_updated = CURRENT_TIMESTAMP; + +INSERT INTO privacy_settings ( + settings_id, + profile_id, + profile_visibility, + health_info_visibility, + location_visibility, + location_sharing_enabled +) +VALUES + ('demo_bogazici_privacy_requester_new_hall', 'demo_bogazici_profile_requester_new_hall', 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_requester_hisarustu', 'demo_bogazici_profile_requester_hisarustu', 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_requester_rumeli', 'demo_bogazici_profile_requester_rumeli', 'EMERGENCY_ONLY'::visibility_level, 'PRIVATE'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_requester_bebek', 'demo_bogazici_profile_requester_bebek', 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_requester_etiler', 'demo_bogazici_profile_requester_etiler', 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', 'EMERGENCY_ONLY'::visibility_level, 'PRIVATE'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_reserve_1', 'demo_bogazici_profile_reserve_1', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_reserve_2', 'demo_bogazici_profile_reserve_2', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_reserve_3', 'demo_bogazici_profile_reserve_3', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_reserve_4', 'demo_bogazici_profile_reserve_4', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_assigned_1', 'demo_bogazici_profile_assigned_1', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_assigned_2', 'demo_bogazici_profile_assigned_2', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_outer_1', 'demo_bogazici_profile_outer_1', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE), + ('demo_bogazici_privacy_outer_2', 'demo_bogazici_profile_outer_2', 'PUBLIC'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, 'EMERGENCY_ONLY'::visibility_level, TRUE) +ON CONFLICT (settings_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + profile_visibility = EXCLUDED.profile_visibility, + health_info_visibility = EXCLUDED.health_info_visibility, + location_visibility = EXCLUDED.location_visibility, + location_sharing_enabled = EXCLUDED.location_sharing_enabled; + +INSERT INTO expertise ( + expertise_id, + profile_id, + profession, + expertise_area, + is_verified +) +VALUES + ('demo_bogazici_expertise_reserve_1', 'demo_bogazici_profile_reserve_1', 'First aid volunteer', '["first_aid","medical_support"]', TRUE), + ('demo_bogazici_expertise_reserve_2', 'demo_bogazici_profile_reserve_2', 'Search and rescue volunteer', '["search_rescue","structural_assessment"]', TRUE), + ('demo_bogazici_expertise_reserve_3', 'demo_bogazici_profile_reserve_3', 'Food and water logistics volunteer', '["food_water","logistics","delivery"]', TRUE), + ('demo_bogazici_expertise_reserve_4', 'demo_bogazici_profile_reserve_4', 'General support volunteer', '["shelter","communications","campus_support"]', TRUE), + ('demo_bogazici_expertise_assigned_1', 'demo_bogazici_profile_assigned_1', 'Campus first aid responder', '["first_aid","medical_support"]', TRUE), + ('demo_bogazici_expertise_assigned_2', 'demo_bogazici_profile_assigned_2', 'Campus logistics responder', '["food_water","logistics"]', TRUE), + ('demo_bogazici_expertise_outer_1', 'demo_bogazici_profile_outer_1', 'Bebek neighborhood support volunteer', '["shelter","communications"]', TRUE), + ('demo_bogazici_expertise_outer_2', 'demo_bogazici_profile_outer_2', 'Etiler supply runner', '["food_water","transport"]', TRUE) +ON CONFLICT (expertise_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + profession = EXCLUDED.profession, + expertise_area = EXCLUDED.expertise_area, + is_verified = EXCLUDED.is_verified; + +INSERT INTO volunteers ( + volunteer_id, + user_id, + is_available, + skills, + need_types, + last_known_latitude, + last_known_longitude, + location_updated_at, + available_until, + availability_confirmed_at, + last_location_accuracy_meters, + last_location_source +) +VALUES + ('demo_bogazici_volunteer_reserve_1', 'demo_bogazici_user_reserve_1', TRUE, ARRAY['first_aid','medical_support'], ARRAY['first_aid'], 41.08455, 29.04320, CURRENT_TIMESTAMP - INTERVAL '6 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '6 minutes', 14, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_reserve_2', 'demo_bogazici_user_reserve_2', TRUE, ARRAY['search_rescue','structural_assessment'], ARRAY['search_rescue'], 41.08720, 29.04365, CURRENT_TIMESTAMP - INTERVAL '7 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '7 minutes', 14, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_reserve_3', 'demo_bogazici_user_reserve_3', TRUE, ARRAY['food_water','logistics','delivery'], ARRAY['food_water'], 41.08390, 29.04190, CURRENT_TIMESTAMP - INTERVAL '8 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '8 minutes', 16, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_reserve_4', 'demo_bogazici_user_reserve_4', TRUE, ARRAY['shelter','communications','general_support'], ARRAY['shelter','food_water'], 41.09020, 29.03980, CURRENT_TIMESTAMP - INTERVAL '9 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '9 minutes', 18, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_assigned_1', 'demo_bogazici_user_assigned_1', TRUE, ARRAY['first_aid','medical_support'], ARRAY['first_aid'], 41.08585, 29.04435, CURRENT_TIMESTAMP - INTERVAL '11 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '11 minutes', 12, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_assigned_2', 'demo_bogazici_user_assigned_2', TRUE, ARRAY['food_water','logistics'], ARRAY['food_water','shelter'], 41.08850, 29.04160, CURRENT_TIMESTAMP - INTERVAL '12 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '12 minutes', 15, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_outer_1', 'demo_bogazici_user_outer_1', TRUE, ARRAY['shelter','communications'], ARRAY['shelter'], 41.07400, 29.04390, CURRENT_TIMESTAMP - INTERVAL '10 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '10 minutes', 20, 'DEMO_DEVICE_GPS'), + ('demo_bogazici_volunteer_outer_2', 'demo_bogazici_user_outer_2', TRUE, ARRAY['food_water','transport'], ARRAY['food_water'], 41.07490, 29.03320, CURRENT_TIMESTAMP - INTERVAL '10 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '10 minutes', 22, 'DEMO_DEVICE_GPS') +ON CONFLICT (volunteer_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + is_available = EXCLUDED.is_available, + skills = EXCLUDED.skills, + need_types = EXCLUDED.need_types, + last_known_latitude = EXCLUDED.last_known_latitude, + last_known_longitude = EXCLUDED.last_known_longitude, + location_updated_at = EXCLUDED.location_updated_at, + available_until = EXCLUDED.available_until, + availability_confirmed_at = EXCLUDED.availability_confirmed_at, + last_location_accuracy_meters = EXCLUDED.last_location_accuracy_meters, + last_location_source = EXCLUDED.last_location_source; + +INSERT INTO availability_records ( + availability_id, + volunteer_id, + is_available, + stored_locally, + synced_at +) +VALUES + ('demo_bogazici_availability_reserve_1', 'demo_bogazici_volunteer_reserve_1', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '6 minutes'), + ('demo_bogazici_availability_reserve_2', 'demo_bogazici_volunteer_reserve_2', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '7 minutes'), + ('demo_bogazici_availability_reserve_3', 'demo_bogazici_volunteer_reserve_3', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '8 minutes'), + ('demo_bogazici_availability_reserve_4', 'demo_bogazici_volunteer_reserve_4', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '9 minutes'), + ('demo_bogazici_availability_assigned_1', 'demo_bogazici_volunteer_assigned_1', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '11 minutes'), + ('demo_bogazici_availability_assigned_2', 'demo_bogazici_volunteer_assigned_2', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '12 minutes'), + ('demo_bogazici_availability_outer_1', 'demo_bogazici_volunteer_outer_1', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '10 minutes'), + ('demo_bogazici_availability_outer_2', 'demo_bogazici_volunteer_outer_2', TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '10 minutes') +ON CONFLICT (availability_id) DO UPDATE SET + volunteer_id = EXCLUDED.volunteer_id, + is_available = EXCLUDED.is_available, + stored_locally = EXCLUDED.stored_locally, + synced_at = EXCLUDED.synced_at; + +INSERT INTO help_requests ( + request_id, + user_id, + help_types, + other_help_text, + affected_people_count, + risk_flags, + vulnerable_groups, + need_type, + description, + blood_type, + share_profile_health_info_with_volunteer, + contact_full_name, + contact_phone, + contact_alternative_phone, + consent_given, + status, + urgency_level, + priority_level, + created_at, + resolved_at, + cancelled_at, + is_saved_locally +) +VALUES + ('demo_bogazici_request_new_hall_medical', 'demo_bogazici_requester_new_hall', ARRAY['first_aid'], '', 1, ARRAY['minor_injury','bleeding'], ARRAY[]::TEXT[], 'first_aid', '[DEMO] Student near New Hall has a bleeding hand cut and needs basic first aid supplies.', 'A+', TRUE, 'Selin Arikan', 5328101101, NULL, TRUE, 'ASSIGNED'::request_status, 'HIGH', 'HIGH', CURRENT_TIMESTAMP - INTERVAL '28 minutes', NULL, NULL, FALSE), + ('demo_bogazici_request_hisarustu_food', 'demo_bogazici_requester_hisarustu', ARRAY['food_water'], '', 4, ARRAY['children'], ARRAY['children'], 'food_water', '[DEMO] Family waiting near Hisarustu needs bottled water and ready-to-eat food packs.', NULL, FALSE, 'Murat Erdem', 5328101102, NULL, TRUE, 'ASSIGNED'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '24 minutes', NULL, NULL, FALSE), + ('demo_bogazici_request_rumeli_search', 'demo_bogazici_requester_rumeli', ARRAY['search_rescue'], '', 2, ARRAY['structural_damage','possible_entrapment'], ARRAY[]::TEXT[], 'search_rescue', '[DEMO] Rumeli Hisari resident reports a damaged stairwell and possible trapped neighbor after shaking.', NULL, FALSE, 'Nihan Turan', 5328101103, NULL, TRUE, 'PENDING'::request_status, 'HIGH', 'HIGH', CURRENT_TIMESTAMP - INTERVAL '22 minutes', NULL, NULL, FALSE), + ('demo_bogazici_request_bebek_shelter', 'demo_bogazici_requester_bebek', ARRAY['shelter'], '', 2, ARRAY['cold_exposure'], ARRAY['elderly'], 'shelter', '[DEMO] Two residents near Bebek need a dry indoor waiting point and blankets.', NULL, FALSE, 'Ozan Yalcin', 5328101104, NULL, TRUE, 'PENDING'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '20 minutes', NULL, NULL, FALSE), + ('demo_bogazici_request_etiler_food_water', 'demo_bogazici_requester_etiler', ARRAY['food_water'], '', 3, ARRAY['elderly'], ARRAY['elderly'], 'food_water', '[DEMO] Elderly neighbors near Nispetiye need water bottles and simple food supplies.', NULL, FALSE, 'Aylin Deniz', 5328101105, NULL, TRUE, 'PENDING'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '18 minutes', NULL, NULL, FALSE), + ('demo_bogazici_request_ucaksavar_support', 'demo_bogazici_requester_ucaksavar', ARRAY['shelter'], 'Urgent campus access coordination and temporary safe waiting area.', 1, ARRAY['mobility_impairment'], ARRAY['disabled'], 'shelter', '[DEMO] Person near Ucaksavar access road needs help reaching a safer indoor waiting area.', NULL, FALSE, 'Kerem Soyer', 5328101106, NULL, TRUE, 'PENDING'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '16 minutes', NULL, NULL, FALSE) +ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + help_types = EXCLUDED.help_types, + other_help_text = EXCLUDED.other_help_text, + affected_people_count = EXCLUDED.affected_people_count, + risk_flags = EXCLUDED.risk_flags, + vulnerable_groups = EXCLUDED.vulnerable_groups, + need_type = EXCLUDED.need_type, + description = EXCLUDED.description, + blood_type = EXCLUDED.blood_type, + share_profile_health_info_with_volunteer = EXCLUDED.share_profile_health_info_with_volunteer, + contact_full_name = EXCLUDED.contact_full_name, + contact_phone = EXCLUDED.contact_phone, + contact_alternative_phone = EXCLUDED.contact_alternative_phone, + consent_given = EXCLUDED.consent_given, + status = EXCLUDED.status, + urgency_level = EXCLUDED.urgency_level, + priority_level = EXCLUDED.priority_level, + created_at = EXCLUDED.created_at, + resolved_at = EXCLUDED.resolved_at, + cancelled_at = EXCLUDED.cancelled_at, + is_saved_locally = EXCLUDED.is_saved_locally; + +INSERT INTO request_locations ( + location_id, + request_id, + country, + city, + district, + neighborhood, + extra_address, + latitude, + longitude, + is_gps_location, + is_last_known, + captured_at +) +VALUES + ('demo_bogazici_location_new_hall_medical', 'demo_bogazici_request_new_hall_medical', 'Turkiye', 'Istanbul', 'Sariyer', 'Rumeli Hisari', 'New Hall entrance, North Campus', 41.08570, 29.04410, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '28 minutes'), + ('demo_bogazici_location_hisarustu_food', 'demo_bogazici_request_hisarustu_food', 'Turkiye', 'Istanbul', 'Sariyer', 'Hisarustu', 'Hisarustu campus gate area, Nispetiye Caddesi', 41.08850, 29.04160, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '24 minutes'), + ('demo_bogazici_location_rumeli_search', 'demo_bogazici_request_rumeli_search', 'Turkiye', 'Istanbul', 'Sariyer', 'Rumeli Hisari', 'Yahya Kemal Caddesi lower slope near Rumeli Hisari', 41.08420, 29.05670, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '22 minutes'), + ('demo_bogazici_location_bebek_shelter', 'demo_bogazici_request_bebek_shelter', 'Turkiye', 'Istanbul', 'Besiktas', 'Bebek', 'Cevdet Pasa Caddesi upper side street', 41.07400, 29.04390, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '20 minutes'), + ('demo_bogazici_location_etiler_food_water', 'demo_bogazici_request_etiler_food_water', 'Turkiye', 'Istanbul', 'Besiktas', 'Etiler', 'Nispetiye Caddesi side street', 41.07460, 29.03310, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '18 minutes'), + ('demo_bogazici_location_ucaksavar_support', 'demo_bogazici_request_ucaksavar_support', 'Turkiye', 'Istanbul', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09120, 29.03480, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '16 minutes') +ON CONFLICT (location_id) DO UPDATE SET + request_id = EXCLUDED.request_id, + country = EXCLUDED.country, + city = EXCLUDED.city, + district = EXCLUDED.district, + neighborhood = EXCLUDED.neighborhood, + extra_address = EXCLUDED.extra_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + is_gps_location = EXCLUDED.is_gps_location, + is_last_known = EXCLUDED.is_last_known, + captured_at = EXCLUDED.captured_at; + +UPDATE assignments +SET is_cancelled = TRUE +WHERE is_cancelled = FALSE + AND ( + ( + volunteer_id IN ( + 'demo_bogazici_volunteer_reserve_1', + 'demo_bogazici_volunteer_reserve_2', + 'demo_bogazici_volunteer_reserve_3', + 'demo_bogazici_volunteer_reserve_4' + ) + AND ( + assignment_id LIKE 'demo_bogazici\_%' ESCAPE '\' + OR request_id LIKE 'demo_bogazici\_%' ESCAPE '\' + ) + ) + OR ( + volunteer_id IN ( + 'demo_bogazici_volunteer_assigned_1', + 'demo_bogazici_volunteer_assigned_2' + ) + AND assignment_id LIKE 'demo_bogazici\_%' ESCAPE '\' + AND assignment_id NOT IN ( + 'demo_bogazici_assignment_new_hall_medical', + 'demo_bogazici_assignment_hisarustu_food' + ) + ) + ); + +INSERT INTO assignments ( + assignment_id, + volunteer_id, + request_id, + assigned_at, + is_cancelled +) +VALUES + ('demo_bogazici_assignment_new_hall_medical', 'demo_bogazici_volunteer_assigned_1', 'demo_bogazici_request_new_hall_medical', CURRENT_TIMESTAMP - INTERVAL '18 minutes', FALSE), + ('demo_bogazici_assignment_hisarustu_food', 'demo_bogazici_volunteer_assigned_2', 'demo_bogazici_request_hisarustu_food', CURRENT_TIMESTAMP - INTERVAL '14 minutes', FALSE) +ON CONFLICT (volunteer_id) WHERE is_cancelled = FALSE DO UPDATE SET + assignment_id = EXCLUDED.assignment_id, + request_id = EXCLUDED.request_id, + assigned_at = EXCLUDED.assigned_at, + is_cancelled = FALSE; + +COMMIT; From 91f9232740d63be00e9ee9ff38fafa7add164010 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 01:35:06 +0300 Subject: [PATCH 05/19] fix: rearrange the location of ucaksavar request --- .../20260512_120000__seed_bogazici_final_demo_data.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql index 65374574..e62c5253 100644 --- a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql +++ b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql @@ -170,7 +170,7 @@ VALUES ('demo_bogazici_location_profile_requester_rumeli', 'demo_bogazici_profile_requester_rumeli', 'Rumeli Hisari, Yahya Kemal Caddesi', 'Rumeli Hisari, Yahya Kemal Caddesi, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Yahya Kemal Caddesi lower slope', 41.08420, 29.05670, 24, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '22 minutes'), ('demo_bogazici_location_profile_requester_bebek', 'demo_bogazici_profile_requester_bebek', 'Bebek, Cevdet Pasa Caddesi', 'Bebek near Cevdet Pasa Caddesi, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Bebek', 'Cevdet Pasa Caddesi upper side street', 41.07400, 29.04390, 25, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '20 minutes'), ('demo_bogazici_location_profile_requester_etiler', 'demo_bogazici_profile_requester_etiler', 'Etiler, Nispetiye Caddesi', 'Nispetiye Caddesi, Etiler, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Nispetiye Caddesi side street', 41.07460, 29.03310, 28, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '18 minutes'), - ('demo_bogazici_location_profile_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', 'Ucaksavar Kampusu yolu', 'Ucaksavar area above Hisarustu, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09120, 29.03480, 30, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '16 minutes'), + ('demo_bogazici_location_profile_requester_ucaksavar', 'demo_bogazici_profile_requester_ucaksavar', 'Ucaksavar Kampusu yolu', 'Ucaksavar area above Hisarustu, Besiktas, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09600, 29.02800, 30, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '16 minutes'), ('demo_bogazici_location_profile_reserve_1', 'demo_bogazici_profile_reserve_1', 'Bogazici North Campus south walkway', 'North Campus south walkway, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'South walkway below New Hall', 41.08455, 29.04320, 14, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '6 minutes'), ('demo_bogazici_location_profile_reserve_2', 'demo_bogazici_profile_reserve_2', 'Bogazici North Campus library side', 'North Campus library side, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Library side path near New Hall', 41.08720, 29.04365, 14, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '7 minutes'), ('demo_bogazici_location_profile_reserve_3', 'demo_bogazici_profile_reserve_3', 'North Campus dining hall service point', 'North Campus dining hall service point, Sariyer, Istanbul', 'Istanbul', 'Turkiye', 'TR', 'Sariyer', 'Rumeli Hisari', 'Student dining hall supply point', 41.08390, 29.04190, 16, 'DEMO_DEVICE_GPS', CURRENT_TIMESTAMP - INTERVAL '8 minutes'), @@ -380,7 +380,7 @@ VALUES ('demo_bogazici_location_rumeli_search', 'demo_bogazici_request_rumeli_search', 'Turkiye', 'Istanbul', 'Sariyer', 'Rumeli Hisari', 'Yahya Kemal Caddesi lower slope near Rumeli Hisari', 41.08420, 29.05670, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '22 minutes'), ('demo_bogazici_location_bebek_shelter', 'demo_bogazici_request_bebek_shelter', 'Turkiye', 'Istanbul', 'Besiktas', 'Bebek', 'Cevdet Pasa Caddesi upper side street', 41.07400, 29.04390, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '20 minutes'), ('demo_bogazici_location_etiler_food_water', 'demo_bogazici_request_etiler_food_water', 'Turkiye', 'Istanbul', 'Besiktas', 'Etiler', 'Nispetiye Caddesi side street', 41.07460, 29.03310, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '18 minutes'), - ('demo_bogazici_location_ucaksavar_support', 'demo_bogazici_request_ucaksavar_support', 'Turkiye', 'Istanbul', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09120, 29.03480, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '16 minutes') + ('demo_bogazici_location_ucaksavar_support', 'demo_bogazici_request_ucaksavar_support', 'Turkiye', 'Istanbul', 'Besiktas', 'Etiler', 'Ucaksavar campus access road', 41.09600, 29.02800, TRUE, FALSE, CURRENT_TIMESTAMP - INTERVAL '16 minutes') ON CONFLICT (location_id) DO UPDATE SET request_id = EXCLUDED.request_id, country = EXCLUDED.country, From 07b2650f116dd51a4c26e2bd92f6dd274f5e49fd Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 11:33:49 +0300 Subject: [PATCH 06/19] doc: prepare submision documents --- README.md | 76 ++++++++++++++++++++++++----- android/.env.example | 7 +++ android/keystore.properties.example | 14 +++--- backend/.env.example | 2 + web/.env.example | 13 +++-- 5 files changed, 90 insertions(+), 22 deletions(-) create mode 100644 android/.env.example diff --git a/README.md b/README.md index 1bfa1d8a..53db8697 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ For Android development: - Android Studio - Android SDK compatible with the project +## Live deployment + +- Web app: `LIVE_WEB_URL_TO_BE_FILLED_BEFORE_RELEASE` +- Backend API: `https://api.neph.app/api` + ## Environment variables Keep secrets and real credentials out of the repository. Use local env files or shell overrides. @@ -105,6 +110,7 @@ Notes: - Email-based flows require valid SMTP credentials. - Placeholder SMTP values in the example file are not usable as-is. - In Docker Compose, the backend connects to PostgreSQL through the internal service name `postgres`. +- Production deployments must use real database credentials and secure secrets. `JWT_SECRET` must not remain at the default value in production. ### Web @@ -119,8 +125,10 @@ Important variables include: Notes: -- `NEXT_PUBLIC_API_BASE_URL` is used for browser-facing requests. -- `API_BASE_URL` can be used for server-side requests inside the Docker network. +- `NEXT_PUBLIC_API_BASE_URL` is the browser/client-side API base. +- `API_BASE_URL` is the server-side Next.js rewrite target. +- `/api` works for Docker and Next.js rewrite usage. +- `http://localhost:3000/api` works for direct backend access outside the rewrite path. ### Docker Compose overrides @@ -169,7 +177,52 @@ Notes: - the backend waits for PostgreSQL to become healthy before starting - the web container is configured to talk to the backend in the local Docker setup -### Optional demo data seed +### Backend setup + +To run the backend directly: + +```bash +cd backend +cp .env.example .env +npm install +npm run migrate +npm start +``` + +Use real database credentials and secure secrets for production. `JWT_SECRET` must not remain at the default value in production. + +### Web setup + +To run the web app directly: + +```bash +cd web +cp .env.example .env.local +npm install +npm run dev +npm run build +npm run start +``` + +Use `NEXT_PUBLIC_API_BASE_URL=/api` with the Next.js rewrite, including Docker. Use `NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api` when the browser should call the backend directly. + +### Mobile setup + +To build the Android app: + +```bash +cd android +cp .env.example .env +cp keystore.properties.example keystore.properties +./gradlew :app:assembleDebug +./gradlew :app:assembleRelease +``` + +Debug Android emulator builds use `http://10.0.2.2:3000/api` to reach the host backend. Do not use `localhost` from inside the emulator for the host backend. Release builds use `NEPH_RELEASE_API_BASE_URL`, defaulting to `https://api.neph.app/api`. + +The signed release APK must be attached to the GitHub Release manually or by the release workflow. + +### Optional legacy demo data seed Demo-ready data is stored as SQL seed migrations under `backend/demo-migrations/`, but it is not applied by the normal backend migration flow. This keeps regular `npm run migrate` and `start:with-migrations` safe for normal environments. @@ -237,17 +290,18 @@ Relevant files: - `web/Dockerfile` - `infra/docker/postgres/init.sql` -## Mobile note - -The Android application lives in `android/`. +## Deployment note -- local debug builds are expected to reach the backend through the emulator bridge at `http://10.0.2.2:3000` -- Android is not part of the root Docker Compose workflow -- mobile build and release steps are handled separately from the local Docker setup +The deployed backend API base is `https://api.neph.app/api`. The deployed web URL must be filled into this README and the GitHub Release notes before final submission if it is not already known. -## Deployment note +## Final release checklist -This README focuses on local development and evaluation. Deployment- and release-related files may exist elsewhere in the repository, but the top-level guide is intentionally centered on how to build and run the project locally. +- Code is merged to `main`. +- Tag `final-milestone` points to the final `main` commit. +- GitHub Release name is `1.0.0`. +- GitHub Release is official, not a pre-release. +- Release notes include the deployed web URL. +- Signed APK is attached to the GitHub Release. ## Development notes diff --git a/android/.env.example b/android/.env.example new file mode 100644 index 00000000..39c88e5a --- /dev/null +++ b/android/.env.example @@ -0,0 +1,7 @@ +NEPH_RELEASE_API_BASE_URL=https://api.neph.app/api +GOOGLE_SERVER_CLIENT_ID=YOUR_GOOGLE_SERVER_CLIENT_ID + +ANDROID_KEYSTORE_PATH=release.keystore +ANDROID_KEYSTORE_PASSWORD=YOUR_KEYSTORE_PASSWORD +ANDROID_KEY_ALIAS=YOUR_KEY_ALIAS +ANDROID_KEY_PASSWORD=YOUR_KEY_PASSWORD diff --git a/android/keystore.properties.example b/android/keystore.properties.example index 062bd582..39c88e5a 100644 --- a/android/keystore.properties.example +++ b/android/keystore.properties.example @@ -1,7 +1,7 @@ -ANDROID_KEYSTORE_PATH=neph-release-key.jks -ANDROID_KEYSTORE_PASSWORD=CHANGE_ME -ANDROID_KEY_ALIAS=neph-release -ANDROID_KEY_PASSWORD=CHANGE_ME -NEPH_RELEASE_API_BASE_URL=https://YOUR-REAL-BACKEND/api -# OAuth – Web Client ID from Google Cloud Console (same project as google-services.json) -GOOGLE_SERVER_CLIENT_ID=YOUR_GOOGLE_SERVER_CLIENT_ID \ No newline at end of file +NEPH_RELEASE_API_BASE_URL=https://api.neph.app/api +GOOGLE_SERVER_CLIENT_ID=YOUR_GOOGLE_SERVER_CLIENT_ID + +ANDROID_KEYSTORE_PATH=release.keystore +ANDROID_KEYSTORE_PASSWORD=YOUR_KEYSTORE_PASSWORD +ANDROID_KEY_ALIAS=YOUR_KEY_ALIAS +ANDROID_KEY_PASSWORD=YOUR_KEY_PASSWORD diff --git a/backend/.env.example b/backend/.env.example index c87ec8ac..6d114bd1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -36,3 +36,5 @@ NOTIFICATION_PENDING_REQUEST_TTL_HOURS=72 HELP_REQUEST_GUEST_CREATE_ENABLED=true HELP_REQUEST_GUEST_MATCHING_ENABLED=false HELP_REQUEST_GUEST_TOKEN_TTL=2h +VOLUNTEER_LOCATION_MAX_AGE_MINUTES=120 +VOLUNTEER_AVAILABILITY_TTL_MINUTES=360 diff --git a/web/.env.example b/web/.env.example index 8bbf785c..93d194e7 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,6 +1,11 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 +# Browser/client-side API base. +# For Docker/Next.js rewrite usage, keep this as /api. +# For direct backend access, use http://localhost:3000/api. +NEXT_PUBLIC_API_BASE_URL=/api -# Google OAuth — required for Google Sign-In -# Must match the GOOGLE_CLIENT_ID set in the backend .env -# Get this from https://console.cloud.google.com/apis/credentials +# Server-side Next.js rewrite target. +API_BASE_URL=http://localhost:3000/api + +# Google OAuth — required for Google Sign-In. +# Must match the GOOGLE_CLIENT_ID set in the backend environment. NEXT_PUBLIC_GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID From b7267ae2f5c0b87af2ab342438184163212ea540 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 11:49:14 +0300 Subject: [PATCH 07/19] doc: improve README file --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 53db8697..3da86d29 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ cp keystore.properties.example keystore.properties Debug Android emulator builds use `http://10.0.2.2:3000/api` to reach the host backend. Do not use `localhost` from inside the emulator for the host backend. Release builds use `NEPH_RELEASE_API_BASE_URL`, defaulting to `https://api.neph.app/api`. +`android/.env.example` is included for submission documentation. Local Gradle builds read release values from `keystore.properties`, environment variables, or Gradle properties. Before running `./gradlew :app:assembleRelease`, replace the placeholder values in `keystore.properties` with real signing values and make sure the keystore file exists at `ANDROID_KEYSTORE_PATH`. Do not include real secrets in the repository. + The signed release APK must be attached to the GitHub Release manually or by the release workflow. ### Optional legacy demo data seed @@ -290,6 +292,14 @@ Relevant files: - `web/Dockerfile` - `infra/docker/postgres/init.sql` +### Production setup + +Production deployment requires backend production environment values for database credentials or `DATABASE_URL`, a secure `JWT_SECRET`, SMTP values if email flows are used, Google OAuth values if Google Sign-In is used, and push notification values if push delivery is enabled. It also requires web production values for `NEXT_PUBLIC_API_BASE_URL` and `API_BASE_URL`. + +Use secure secrets instead of demo/default values. Start the backend with the normal migration-enabled startup flow so SQL migrations are applied before serving traffic. + +Before final release, verify the backend health endpoint, the backend API base at `https://api.neph.app/api`, and the deployed web URL. The signed Android APK must be attached to the GitHub Release. + ## Deployment note The deployed backend API base is `https://api.neph.app/api`. The deployed web URL must be filled into this README and the GitHub Release notes before final submission if it is not already known. From 897abe866c687a702cc55d2e867c58f6844a4a3c Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 11:57:12 +0300 Subject: [PATCH 08/19] fix: assignment overwrite risk --- .../20260512_120000__seed_bogazici_final_demo_data.sql | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql index e62c5253..d83a864d 100644 --- a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql +++ b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql @@ -433,10 +433,8 @@ INSERT INTO assignments ( VALUES ('demo_bogazici_assignment_new_hall_medical', 'demo_bogazici_volunteer_assigned_1', 'demo_bogazici_request_new_hall_medical', CURRENT_TIMESTAMP - INTERVAL '18 minutes', FALSE), ('demo_bogazici_assignment_hisarustu_food', 'demo_bogazici_volunteer_assigned_2', 'demo_bogazici_request_hisarustu_food', CURRENT_TIMESTAMP - INTERVAL '14 minutes', FALSE) -ON CONFLICT (volunteer_id) WHERE is_cancelled = FALSE DO UPDATE SET - assignment_id = EXCLUDED.assignment_id, - request_id = EXCLUDED.request_id, - assigned_at = EXCLUDED.assigned_at, - is_cancelled = FALSE; +-- Preserve any existing active assignment in shared environments; never rewrite +-- a non-demo assignment into a demo assignment. +ON CONFLICT (volunteer_id) WHERE is_cancelled = FALSE DO NOTHING; COMMIT; From bb137db6d9f3b758dc5c725cb9b00aee1c90f362 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 12:05:04 +0300 Subject: [PATCH 09/19] fix: safe assignment insert --- ..._120000__seed_bogazici_final_demo_data.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql index d83a864d..f467f661 100644 --- a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql +++ b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql @@ -437,4 +437,24 @@ VALUES -- a non-demo assignment into a demo assignment. ON CONFLICT (volunteer_id) WHERE is_cancelled = FALSE DO NOTHING; +-- Keep demo request status consistent when DO NOTHING preserves an existing +-- non-demo active assignment for the intended demo volunteer. +UPDATE help_requests hr +SET status = CASE + WHEN EXISTS ( + SELECT 1 + FROM assignments a + WHERE a.assignment_id = expected.assignment_id + AND a.request_id = expected.request_id + AND a.is_cancelled = FALSE + ) THEN 'ASSIGNED'::request_status + ELSE 'PENDING'::request_status +END +FROM ( + VALUES + ('demo_bogazici_request_new_hall_medical', 'demo_bogazici_assignment_new_hall_medical'), + ('demo_bogazici_request_hisarustu_food', 'demo_bogazici_assignment_hisarustu_food') +) AS expected(request_id, assignment_id) +WHERE hr.request_id = expected.request_id; + COMMIT; From e7a94724f3d0e3d3974a62f107c174fd71f66451 Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 12:16:18 +0300 Subject: [PATCH 10/19] fix: conflict handling --- .../20260512_120000__seed_bogazici_final_demo_data.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql index f467f661..68d41ca4 100644 --- a/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql +++ b/backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql @@ -433,9 +433,9 @@ INSERT INTO assignments ( VALUES ('demo_bogazici_assignment_new_hall_medical', 'demo_bogazici_volunteer_assigned_1', 'demo_bogazici_request_new_hall_medical', CURRENT_TIMESTAMP - INTERVAL '18 minutes', FALSE), ('demo_bogazici_assignment_hisarustu_food', 'demo_bogazici_volunteer_assigned_2', 'demo_bogazici_request_hisarustu_food', CURRENT_TIMESTAMP - INTERVAL '14 minutes', FALSE) --- Preserve any existing active assignment in shared environments; never rewrite --- a non-demo assignment into a demo assignment. -ON CONFLICT (volunteer_id) WHERE is_cancelled = FALSE DO NOTHING; +-- Preserve existing assignment rows in shared environments; never overwrite +-- non-demo data or fail the seed because an assignment already exists. +ON CONFLICT DO NOTHING; -- Keep demo request status consistent when DO NOTHING preserves an existing -- non-demo active assignment for the intended demo volunteer. From 2a76528393a2f0872fbb42e3cb141d0e6af9f81c Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 12:34:43 +0300 Subject: [PATCH 11/19] doc: add new demo data into README --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3da86d29..37e28472 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,29 @@ Debug Android emulator builds use `http://10.0.2.2:3000/api` to reach the host b The signed release APK must be attached to the GitHub Release manually or by the release workflow. +### Final Boğaziçi demo data + +Final demo data is stored in the normal backend migration flow: + +- `backend/migrations/20260512_120000__seed_bogazici_final_demo_data.sql` + +It is applied by the normal backend migration runner. In Docker Compose, backend migrations run automatically on startup, so no separate `ENABLE_DEMO_SEED=true npm run seed:demo` command is needed for the Boğaziçi final demo data after migrations run. The old `backend/demo-migrations/` flow is optional legacy demo data only. + +Boğaziçi final demo accounts: + +| Email | Password | +| --- | --- | +| `bogazici_reserve_1@neph.test` | `DemoPass123!` | +| `bogazici_reserve_2@neph.test` | `DemoPass123!` | +| `bogazici_reserve_3@neph.test` | `DemoPass123!` | +| `bogazici_reserve_4@neph.test` | `DemoPass123!` | +| `bogazici_assigned_1@neph.test` | `DemoPass123!` | +| `bogazici_assigned_2@neph.test` | `DemoPass123!` | +| `bogazici_requester_new_hall@neph.test` | `DemoPass123!` | + ### Optional legacy demo data seed -Demo-ready data is stored as SQL seed migrations under `backend/demo-migrations/`, but it is not applied by the normal backend migration flow. This keeps regular `npm run migrate` and `start:with-migrations` safe for normal environments. +Additional legacy demo-ready data is stored as SQL seed migrations under `backend/demo-migrations/`, but it is not applied by the normal backend migration flow. This keeps regular `npm run migrate` and `start:with-migrations` safe for normal environments. To apply the demo dataset intentionally, run the regular migrations first and then run the guarded demo seed command: @@ -292,7 +312,7 @@ Relevant files: - `web/Dockerfile` - `infra/docker/postgres/init.sql` -### Production setup +## Production setup Production deployment requires backend production environment values for database credentials or `DATABASE_URL`, a secure `JWT_SECRET`, SMTP values if email flows are used, Google OAuth values if Google Sign-In is used, and push notification values if push delivery is enabled. It also requires web production values for `NEXT_PUBLIC_API_BASE_URL` and `API_BASE_URL`. From 6f0b8c46039ce46ad3224298fd2ab9c77d1d436b Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 12:45:10 +0300 Subject: [PATCH 12/19] fix: replace placeholder url with neph url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37e28472..cea1efa7 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ For Android development: ## Live deployment -- Web app: `LIVE_WEB_URL_TO_BE_FILLED_BEFORE_RELEASE` +- Web app: `https://neph.app/` - Backend API: `https://api.neph.app/api` ## Environment variables From b70c60a9505499b40e91093f496176be80b0020e Mon Sep 17 00:00:00 2001 From: kckagancan <118129306+kckagancan@users.noreply.github.com> Date: Tue, 12 May 2026 13:03:08 +0300 Subject: [PATCH 13/19] fix(android): navigate after edited help request submission --- .../requesthelp/presentation/RequestHelpScreen.kt | 10 ++++++---- .../src/main/java/com/neph/navigation/AppNavGraph.kt | 10 +++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt index 5927637b..050de41e 100644 --- a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt +++ b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt @@ -934,8 +934,10 @@ fun RequestHelpScreen( loading = true scope.launch { try { + val editLocalId = activeDraftLocalId.takeIf { it.isNotBlank() } + val existingDraft = editLocalId?.let { RequestHelpRepository.getLocalHelpRequest(it) } if (isLoggedIn) { - val hasActiveRequest = activeDraftLocalId.isBlank() && RequestHelpRepository.hasActiveHelpRequest(sessionToken) + val hasActiveRequest = existingDraft == null && RequestHelpRepository.hasActiveHelpRequest(sessionToken) if (hasActiveRequest) { errorMessage = "You can only have one active help request at a time." return@launch @@ -948,10 +950,10 @@ fun RequestHelpScreen( } val submission = buildSubmission(formState, availableLocationData) - val result = if (activeDraftLocalId.isNotBlank()) { + val result = if (editLocalId != null && existingDraft != null) { RequestHelpRepository.updateHelpRequest( token = sessionToken, - localId = activeDraftLocalId, + localId = editLocalId, submission = submission, preserveExistingCoordinates = !formState.locationWasManuallyChanged ) @@ -961,7 +963,7 @@ fun RequestHelpScreen( submission = submission ) } - activeDraftLocalId = result.requestId + activeDraftLocalId = if (existingDraft != null) "" else result.requestId infoMessage = "Help request saved on this device and queued for sync." if (isSendOnboardingTarget) { completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_SEND) diff --git a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt index 9681625d..22b1e390 100644 --- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt @@ -502,9 +502,13 @@ fun AppNavGraph( navigateToLogin() }, onNavigateToMyHelpRequests = { - val popped = navController.popBackStack(Routes.MyHelpRequests.route, inclusive = false) - if (!popped) { - navigateToDrawerRoute(Routes.MyHelpRequests.route) + navController.navigate(Routes.MyHelpRequests.route) { + popUpTo(Routes.Home.route) { + inclusive = false + saveState = false + } + launchSingleTop = true + restoreState = false } }, mobileOnboardingStepId = mobileOnboardingStepId, From cd34696efd738cd2b6aa53240fcdfca5037526be Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:17:24 +0300 Subject: [PATCH 14/19] fix(android-news): load announcements from backend and add detail screen --- .../news/data/AnnouncementsRepository.kt | 50 +++ .../presentation/AnnouncementDetailScreen.kt | 149 +++++++ .../news/presentation/NewsFormatters.kt | 18 + .../features/news/presentation/NewsScreen.kt | 365 +++++++++--------- .../java/com/neph/navigation/AppNavGraph.kt | 19 + .../main/java/com/neph/navigation/Routes.kt | 6 + 6 files changed, 435 insertions(+), 172 deletions(-) create mode 100644 android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt create mode 100644 android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt create mode 100644 android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt diff --git a/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt new file mode 100644 index 00000000..afba3aee --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt @@ -0,0 +1,50 @@ +package com.neph.features.news.data + +import com.neph.core.network.JsonHttpClient +import org.json.JSONObject + +data class Announcement( + val id: String, + val adminId: String?, + val title: String, + val content: String, + val createdAt: String? +) + +object AnnouncementsRepository { + private const val DefaultLimit = 100 + + suspend fun fetchAnnouncements(limit: Int = DefaultLimit): List { + val normalizedLimit = limit.coerceIn(1, 200) + val response = JsonHttpClient.request( + path = "/announcements?limit=$normalizedLimit" + ) + + val rawList = response.optJSONArray("announcements") ?: return emptyList() + val items = mutableListOf() + for (i in 0 until rawList.length()) { + val obj = rawList.optJSONObject(i) ?: continue + items.add(parseAnnouncement(obj)) + } + return items + } + + suspend fun fetchAnnouncement(announcementId: String): Announcement { + val response = JsonHttpClient.request( + path = "/announcements/${java.net.URLEncoder.encode(announcementId, Charsets.UTF_8.name())}" + ) + val obj = response.optJSONObject("announcement") + ?: throw IllegalStateException("Announcement response did not include an announcement object.") + return parseAnnouncement(obj) + } + + private fun parseAnnouncement(json: JSONObject): Announcement { + return Announcement( + id = json.optString("id"), + adminId = json.optString("adminId").takeIf { it.isNotBlank() }, + title = json.optString("title"), + content = json.optString("content"), + createdAt = json.optString("createdAt").takeIf { it.isNotBlank() } + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt b/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt new file mode 100644 index 00000000..e8acae9c --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt @@ -0,0 +1,149 @@ +package com.neph.features.news.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import com.neph.features.news.data.Announcement +import com.neph.features.news.data.AnnouncementsRepository +import com.neph.ui.components.buttons.PrimaryButton +import com.neph.ui.components.display.SectionCard +import com.neph.ui.components.display.StatusBadge +import com.neph.ui.components.display.StatusBadgeTone +import com.neph.ui.layout.AppScaffold +import com.neph.ui.theme.LocalNephSpacing +import kotlinx.coroutines.CancellationException + +private sealed interface DetailUiState { + data object Loading : DetailUiState + data class Loaded(val announcement: Announcement) : DetailUiState + data class Error(val message: String) : DetailUiState +} + +@Composable +fun AnnouncementDetailScreen( + announcementId: String, + onNavigateBack: () -> Unit +) { + val spacing = LocalNephSpacing.current + var state by remember { mutableStateOf(DetailUiState.Loading) } + var reloadKey by remember { mutableStateOf(0) } + + LaunchedEffect(announcementId, reloadKey) { + state = DetailUiState.Loading + try { + val announcement = AnnouncementsRepository.fetchAnnouncement(announcementId) + state = DetailUiState.Loaded(announcement) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + state = DetailUiState.Error( + error.message?.takeIf { it.isNotBlank() } + ?: "Could not load this announcement. Please try again." + ) + } + } + + AppScaffold( + title = "Announcement", + onNavigateBack = onNavigateBack + ) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing.lg) + ) { + SectionCard { + when (val current = state) { + is DetailUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(spacing.lg), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is DetailUiState.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Couldn't load announcement", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = current.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Retry", + onClick = { reloadKey += 1 }, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } + } + is DetailUiState.Loaded -> { + AnnouncementDetailContent(announcement = current.announcement) + } + } + } + } + } +} + +@Composable +private fun AnnouncementDetailContent(announcement: Announcement) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + StatusBadge( + text = "Announcement", + tone = StatusBadgeTone.BRAND + ) + + Text( + text = announcement.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + + val publishedAt = formatPublishedAt(announcement.createdAt) + if (publishedAt.isNotBlank()) { + Text( + text = publishedAt, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = announcement.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt b/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt new file mode 100644 index 00000000..200605a3 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt @@ -0,0 +1,18 @@ +package com.neph.features.news.presentation + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val DisplayDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneId.systemDefault()) + +internal fun formatPublishedAt(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + DisplayDateFormatter.format(Instant.parse(value)) + } catch (_: Exception) { + value + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt index aedd4b37..3a3be83f 100644 --- a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt +++ b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt @@ -1,147 +1,86 @@ package com.neph.features.news.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.Modifier +import com.neph.features.news.data.Announcement +import com.neph.features.news.data.AnnouncementsRepository import com.neph.navigation.Routes +import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.display.SectionCard import com.neph.ui.components.display.StatusBadge import com.neph.ui.components.display.StatusBadgeTone import com.neph.ui.layout.AppDrawerScaffold import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephTheme +import kotlinx.coroutines.CancellationException -private data class NewsItem( - val id: String, - val title: String, - val summary: String, - val publishedAt: String, - val category: String -) +private sealed interface NewsUiState { + data object Loading : NewsUiState + data class Loaded(val items: List) : NewsUiState + data class Error(val message: String) : NewsUiState +} + +private const val SummaryMaxLength = 180 -private val mockNews = listOf( - NewsItem( - id = "n-001", - title = "Neighborhood Preparedness Workshops Start Next Week", - summary = "Local volunteer teams will host practical first-response workshops for households in participating districts.", - publishedAt = "2026-03-20", - category = "Preparedness" - ), - NewsItem( - id = "n-002", - title = "Mobile App Pilot Open for Early Access", - summary = "NEPH mobile pilot is available for selected users to test incident reporting and emergency support requests.", - publishedAt = "2026-03-18", - category = "Announcement" - ), - NewsItem( - id = "n-003", - title = "Community Safety Volunteers Expanded", - summary = "New volunteers have joined the response network, improving local coverage for urgent coordination.", - publishedAt = "2026-03-13", - category = "Community" - ), - NewsItem( - id = "n-004", - title = "Medical Information Checklist Updated", - summary = "The profile health checklist now includes clearer guidance for medications and chronic conditions.", - publishedAt = "2026-03-09", - category = "Preparedness" - ), - NewsItem( - id = "n-005", - title = "District Evacuation Route Signage Refreshed", - summary = "Municipal teams completed replacement of damaged evacuation signs across high-risk neighborhoods.", - publishedAt = "2026-03-06", - category = "Announcement" - ), - NewsItem( - id = "n-006", - title = "Volunteer Radio Drill Scheduled for Saturday", - summary = "Community coordinators will run a communication drill to validate fallback channels during outages.", - publishedAt = "2026-03-04", - category = "Community" - ), - NewsItem( - id = "n-007", - title = "Water Stocking Guidance Updated for Families", - summary = "Preparedness guidelines now include age-based daily water recommendations for households.", - publishedAt = "2026-03-01", - category = "Preparedness" - ), - NewsItem( - id = "n-008", - title = "Search and Rescue Teams Added in Northern Zone", - summary = "Two new rapid-response teams were integrated to reduce arrival times in remote areas.", - publishedAt = "2026-02-26", - category = "Community" - ), - NewsItem( - id = "n-009", - title = "Critical Medication Reminder Campaign Launched", - summary = "A new campaign reminds patients to maintain a 7-day emergency medication reserve.", - publishedAt = "2026-02-24", - category = "Announcement" - ), - NewsItem( - id = "n-010", - title = "Neighborhood Assembly Point Maps Published", - summary = "Updated assembly point maps are now available with improved accessibility details.", - publishedAt = "2026-02-20", - category = "Preparedness" - ), - NewsItem( - id = "n-011", - title = "Local First-Aid Mentor Program Expanded", - summary = "The mentor network now covers 14 additional schools and public training centers.", - publishedAt = "2026-02-16", - category = "Community" - ), - NewsItem( - id = "n-012", - title = "Power Outage Kit Checklist Shared", - summary = "NEPH published a concise checklist for household lighting, charging, and communication readiness.", - publishedAt = "2026-02-12", - category = "Preparedness" - ) -) +private data class AnnouncementSummary(val text: String, val hasMore: Boolean) + +private fun summarizeAnnouncementContent(content: String): AnnouncementSummary { + val normalized = content.replace(Regex("\\s+"), " ").trim() + if (normalized.length <= SummaryMaxLength) { + return AnnouncementSummary(text = normalized, hasMore = false) + } + val truncated = normalized.substring(0, SummaryMaxLength - 1).trim() + return AnnouncementSummary(text = "$truncated…", hasMore = true) +} @Composable fun NewsScreen( onNavigateToRoute: (String) -> Unit, onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, + onOpenAnnouncement: (String) -> Unit, profileBadgeText: String, isAuthenticated: Boolean ) { val spacing = LocalNephSpacing.current - val categories = remember { listOf("All", "Preparedness", "Announcement", "Community") } - var selectedCategory by remember { mutableStateOf("All") } - var isFilterExpanded by remember { mutableStateOf(false) } + var state by remember { mutableStateOf(NewsUiState.Loading) } + var reloadKey by remember { mutableStateOf(0) } - val filteredNews = remember(selectedCategory) { - if (selectedCategory == "All") { - mockNews - } else { - mockNews.filter { it.category == selectedCategory } + LaunchedEffect(reloadKey) { + state = NewsUiState.Loading + try { + val announcements = AnnouncementsRepository.fetchAnnouncements() + state = NewsUiState.Loaded(announcements) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + state = NewsUiState.Error( + error.message?.takeIf { it.isNotBlank() } + ?: "Could not load announcements. Please try again." + ) } } @@ -170,85 +109,166 @@ fun NewsScreen( verticalArrangement = Arrangement.spacedBy(spacing.lg) ) { SectionCard(modifier = Modifier.weight(1f)) { + Text( + text = "Updates", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + + when (val current = state) { + is NewsUiState.Loading -> LoadingState() + is NewsUiState.Error -> ErrorState( + message = current.message, + onRetry = { reloadKey += 1 } + ) + is NewsUiState.Loaded -> { + if (current.items.isEmpty()) { + EmptyState(onRetry = { reloadKey += 1 }) + } else { + AnnouncementList( + items = current.items, + onOpenAnnouncement = onOpenAnnouncement + ) + } + } + } + } + } + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Couldn't load announcements", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Retry", + onClick = onRetry, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } +} + +@Composable +private fun EmptyState(onRetry: () -> Unit) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No announcements yet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Check back later for updates from administrators.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Refresh", + onClick = onRetry, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } +} + +@Composable +private fun AnnouncementList( + items: List, + onOpenAnnouncement: (String) -> Unit +) { + val spacing = LocalNephSpacing.current + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(top = spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md) + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + val summary = remember(item.id, item.content) { + summarizeAnnouncementContent(item.content) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenAnnouncement(item.id) }, + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - text = "Updates", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + StatusBadge( + text = "Announcement", + tone = StatusBadgeTone.BRAND ) - Row { - TextButton(onClick = { isFilterExpanded = true }) { - Text(text = selectedCategory) - } - - DropdownMenu( - expanded = isFilterExpanded, - onDismissRequest = { isFilterExpanded = false } - ) { - categories.forEach { category -> - DropdownMenuItem( - text = { Text(category) }, - onClick = { - selectedCategory = category - isFilterExpanded = false - } - ) - } - } - } + Text( + text = formatPublishedAt(item.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(spacing.md) - ) { - itemsIndexed(filteredNews, key = { _, item -> item.id }) { index, item -> - Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - StatusBadge( - text = item.category, - tone = when (item.category) { - "Preparedness" -> StatusBadgeTone.INFO - "Announcement" -> StatusBadgeTone.BRAND - "Community" -> StatusBadgeTone.SUCCESS - else -> StatusBadgeTone.NEUTRAL - } - ) + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) - Text( - text = item.publishedAt, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = summary.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Text( - text = item.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold - ) - - Text( - text = item.summary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = "Open announcement", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } - if (index < filteredNews.lastIndex) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - } - } + if (index < items.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -262,6 +282,7 @@ private fun NewsScreenPreview() { onNavigateToRoute = {}, onOpenSettings = {}, onProfileClick = {}, + onOpenAnnouncement = {}, profileBadgeText = "PP", isAuthenticated = true ) diff --git a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt index 9681625d..bb86e33c 100644 --- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt @@ -29,6 +29,7 @@ import com.neph.features.home.presentation.HomeScreen import com.neph.features.myhelprequests.presentation.MyHelpRequestsScreen import com.neph.features.nearbyusers.presentation.NearbyVisibleUsersScreen import com.neph.features.news.presentation.NewsScreen +import com.neph.features.news.presentation.AnnouncementDetailScreen import com.neph.features.notifications.presentation.NotificationsScreen import com.neph.features.privacysecurity.presentation.PrivacySecurityScreen import com.neph.features.profile.data.ProfileRepository @@ -172,11 +173,29 @@ fun AppNavGraph( navigateToLogin() } }, + onOpenAnnouncement = { announcementId -> + navController.navigate(Routes.newsDetail(announcementId)) + }, profileBadgeText = profileBadgeText, isAuthenticated = authenticated ) } + composable( + route = "${Routes.NewsDetail.route}/{${Routes.NewsAnnouncementIdArg}}", + arguments = listOf( + navArgument(Routes.NewsAnnouncementIdArg) { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val announcementId = backStackEntry.arguments?.getString(Routes.NewsAnnouncementIdArg).orEmpty() + AnnouncementDetailScreen( + announcementId = announcementId, + onNavigateBack = { navController.popBackStack() } + ) + } + composable(Routes.MyHelpRequests.route) { val authenticated = isAuthenticated() val profileBadgeText = resolveProfileBadgeText(authenticated) diff --git a/android/app/src/main/java/com/neph/navigation/Routes.kt b/android/app/src/main/java/com/neph/navigation/Routes.kt index e7c9dc4e..b6d41c1b 100644 --- a/android/app/src/main/java/com/neph/navigation/Routes.kt +++ b/android/app/src/main/java/com/neph/navigation/Routes.kt @@ -6,6 +6,7 @@ sealed class Routes( ) { data object Home : Routes("home", "Home") data object News : Routes("news", "News & Announcements") + data object NewsDetail : Routes("news_detail", "Announcement") data object RequestHelp : Routes("request_help") data object MyHelpRequests : Routes("my_help_requests", "My Help Requests") data object AssignedRequest : Routes("assigned_request", "Assigned Request") @@ -34,6 +35,11 @@ sealed class Routes( fun requestHelpWithDraft(localId: String): String = "${RequestHelp.route}?$RequestHelpDraftArg=$localId" + const val NewsAnnouncementIdArg = "announcementId" + + fun newsDetail(announcementId: String): String = + "${NewsDetail.route}/$announcementId" + val authenticatedDrawerItems = listOf( Home, News, From 78f469a01d2b242955c6aefaba8149d8441f78df Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:17:47 +0300 Subject: [PATCH 15/19] fix(web-news): remove demo fallback content and unify open announcement labels --- web/src/app/home/page.tsx | 28 +++++++------- web/src/app/news/[announcementId]/page.tsx | 10 ++--- web/src/app/news/page.tsx | 45 +++++++++++++++++----- web/src/lib/news.ts | 27 ------------- 4 files changed, 53 insertions(+), 57 deletions(-) diff --git a/web/src/app/home/page.tsx b/web/src/app/home/page.tsx index abec92a7..d042a4d9 100644 --- a/web/src/app/home/page.tsx +++ b/web/src/app/home/page.tsx @@ -12,7 +12,6 @@ import { type EmergencyContact, } from "../../lib/emergencyNumbers"; import { - FALLBACK_ANNOUNCEMENTS, announcementToNewsItem, fetchAnnouncements, readCachedAnnouncements, @@ -109,19 +108,22 @@ export default function HomePage() { const cached = readCachedAnnouncements(); const cachedAnnouncements = cached?.announcements.slice(0, 3) || []; const hasCachedAnnouncements = cachedAnnouncements.length > 0; - const fallbackAnnouncements = hasCachedAnnouncements - ? cachedAnnouncements - : FALLBACK_ANNOUNCEMENTS.slice(0, 3); - const sourceLabel = hasCachedAnnouncements - ? "cached announcements" - : "demo announcements"; - setPreviewNews(fallbackAnnouncements.map(announcementToNewsItem)); - setNewsUpdatedAt(hasCachedAnnouncements && cached ? cached.savedAt : FALLBACK_ANNOUNCEMENTS[0]?.createdAt || ""); - setUsingFallbackNews(true); - setNewsError( - `Latest announcements could not be refreshed (${describeNewsPreviewFailure(err)}). Showing ${sourceLabel}.` - ); + if (hasCachedAnnouncements) { + setPreviewNews(cachedAnnouncements.map(announcementToNewsItem)); + setNewsUpdatedAt(cached?.savedAt || ""); + setUsingFallbackNews(true); + setNewsError( + `Latest announcements could not be refreshed (${describeNewsPreviewFailure(err)}). Showing cached announcements.` + ); + } else { + setPreviewNews([]); + setNewsUpdatedAt(""); + setUsingFallbackNews(true); + setNewsError( + `Latest announcements could not be loaded (${describeNewsPreviewFailure(err)}).` + ); + } } finally { setNewsLoading(false); } diff --git a/web/src/app/news/[announcementId]/page.tsx b/web/src/app/news/[announcementId]/page.tsx index 1b29449b..af6e4d57 100644 --- a/web/src/app/news/[announcementId]/page.tsx +++ b/web/src/app/news/[announcementId]/page.tsx @@ -9,7 +9,6 @@ import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { ANNOUNCEMENTS_CACHE_KEY, - FALLBACK_ANNOUNCEMENTS, fetchAnnouncement, formatAnnouncementDate, type Announcement, @@ -70,16 +69,13 @@ export default function NewsDetailPage() { const nextAnnouncement = await fetchAnnouncement(announcementId); setAnnouncement(nextAnnouncement); } catch (err) { - const fallback = - findCachedAnnouncement(announcementId) || - FALLBACK_ANNOUNCEMENTS.find((item) => item.id === announcementId) || - null; + const fallback = findCachedAnnouncement(announcementId); if (fallback) { const detail = describeAnnouncementFailure(err); setAnnouncement(fallback); setUsingFallback(true); - setError(`Announcement could not be refreshed (${detail}). Showing cached/demo content.`); + setError(`Announcement could not be refreshed (${detail}). Showing cached content.`); } else { setError(`Announcement could not be refreshed (${describeAnnouncementFailure(err)}).`); setAnnouncement(null); @@ -115,7 +111,7 @@ export default function NewsDetailPage() {

- {usingFallback ? "Using fallback announcement" : "Announcement load failed"} + {usingFallback ? "Using cached announcement" : "Announcement load failed"}

{error}

diff --git a/web/src/app/news/page.tsx b/web/src/app/news/page.tsx index 59eea65e..66832801 100644 --- a/web/src/app/news/page.tsx +++ b/web/src/app/news/page.tsx @@ -7,7 +7,6 @@ import { SectionCard } from "@/components/ui/display/SectionCard"; import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { - FALLBACK_ANNOUNCEMENTS, announcementToNewsItem, cacheAnnouncements, fetchAnnouncements, @@ -28,16 +27,26 @@ function buildFailureMessage(err: unknown, sourceLabel: string) { return `Announcements could not be refreshed (${detail}). Showing ${sourceLabel} instead.`; } +function buildLoadFailureMessage(err: unknown) { + const rawDetail = err instanceof Error ? err.message : ""; + const detail = /could not reach the server/i.test(rawDetail) + ? "the live announcements service did not respond" + : rawDetail || "the announcements API did not respond"; + return `Could not load announcements (${detail}).`; +} + export default function NewsPage() { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); + const [loadError, setLoadError] = React.useState(""); const [lastUpdated, setLastUpdated] = React.useState(""); const [sourceLabel, setSourceLabel] = React.useState("live announcements"); const loadAnnouncements = React.useCallback(async () => { setLoading(true); setError(""); + setLoadError(""); try { const announcements = await fetchAnnouncements({ limit: 100 }); @@ -55,10 +64,10 @@ export default function NewsPage() { setSourceLabel("cached announcements"); setError(buildFailureMessage(err, "cached announcements")); } else { - setItems(FALLBACK_ANNOUNCEMENTS.map(announcementToNewsItem)); - setLastUpdated(FALLBACK_ANNOUNCEMENTS[0]?.createdAt || ""); - setSourceLabel("demo announcements"); - setError(buildFailureMessage(err, "demo announcements")); + setItems([]); + setLastUpdated(""); + setSourceLabel(""); + setLoadError(buildLoadFailureMessage(err)); } } finally { setLoading(false); @@ -91,13 +100,25 @@ export default function NewsPage() {
) : ( <> -
+

- {error ? "Using fallback news" : "Announcements are up to date"} + {loadError + ? "Couldn't load announcements" + : error + ? "Using cached announcements" + : "Announcements are up to date"}

- {error || `Showing ${sourceLabel}.`} + {loadError || error || `Showing ${sourceLabel}.`}

{lastUpdated ? (

@@ -112,7 +133,11 @@ export default function NewsPage() { {items.length === 0 ? (

-

No announcements have been published yet.

+

+ {loadError + ? "No announcements could be loaded right now." + : "No announcements have been published yet."} +

) : (
@@ -131,7 +156,7 @@ export default function NewsPage() { className="news-read-more-link" href={`/news/${encodeURIComponent(item.id)}`} > - {item.hasMore ? "Read full announcement" : "Open announcement"} + Open announcement ))} diff --git a/web/src/lib/news.ts b/web/src/lib/news.ts index fee3e810..e44075c0 100644 --- a/web/src/lib/news.ts +++ b/web/src/lib/news.ts @@ -34,33 +34,6 @@ export type AnnouncementCache = { savedAt: string; }; -export const FALLBACK_ANNOUNCEMENTS: Announcement[] = [ - { - id: "seed_announcement_gathering_area", - adminId: "seed", - title: "Know your nearest gathering area", - content: - "Check the gathering areas page and keep your location information up to date so emergency guidance can stay relevant.", - createdAt: "2026-04-29T12:10:00.000Z", - }, - { - id: "seed_announcement_volunteer_expansion", - adminId: "seed", - title: "Community safety volunteers are expanding", - content: - "New volunteer coordination improvements are being prepared to help communities respond faster during emergencies.", - createdAt: "2026-04-29T12:05:00.000Z", - }, - { - id: "seed_announcement_preparedness_checklist", - adminId: "seed", - title: "Preparedness checklist updated", - content: - "Review your household emergency bag, contact list, medication details, and nearest gathering area before an emergency occurs.", - createdAt: "2026-04-29T12:00:00.000Z", - }, -]; - function buildAnnouncementsPath(options: { limit?: number } = {}) { const params = new URLSearchParams(); if (typeof options.limit === "number") { From 23fa5c8390cb049abb525eae2a0b9bf9399ae597 Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:17:55 +0300 Subject: [PATCH 16/19] fix(web): force en-US timestamp formatting --- web/src/lib/formatters.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/formatters.ts b/web/src/lib/formatters.ts index d63442c8..43b29783 100644 --- a/web/src/lib/formatters.ts +++ b/web/src/lib/formatters.ts @@ -64,7 +64,7 @@ export function formatTimestampDate(value: string, now = new Date()) { return relativeLabel; } - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", @@ -79,13 +79,13 @@ export function formatTimestampDateTime(value: string, now = new Date()) { const relativeLabel = getRelativeDayLabel(date, now); if (relativeLabel) { - return `${relativeLabel}, ${date.toLocaleTimeString(undefined, { + return `${relativeLabel}, ${date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", })}`; } - return date.toLocaleString(undefined, { + return date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", }); From 8d04508fc47ba606fd633a042eb26949bb02a8d6 Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:31:14 +0300 Subject: [PATCH 17/19] test(news): align fallback e2e with explicit-error behavior - update news-fallback spec for no-cache error state - add cached-announcements fallback path e2e coverage - clamp android announcements limit to backend max (100) - remove unused hasMore from Android news summary helper --- .../news/data/AnnouncementsRepository.kt | 3 +- .../features/news/presentation/NewsScreen.kt | 10 ++-- web/e2e/news-fallback.spec.js | 50 +++++++++++++++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt index afba3aee..0de1fec2 100644 --- a/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt +++ b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt @@ -13,9 +13,10 @@ data class Announcement( object AnnouncementsRepository { private const val DefaultLimit = 100 + private const val MaxLimit = 100 suspend fun fetchAnnouncements(limit: Int = DefaultLimit): List { - val normalizedLimit = limit.coerceIn(1, 200) + val normalizedLimit = limit.coerceIn(1, MaxLimit) val response = JsonHttpClient.request( path = "/announcements?limit=$normalizedLimit" ) diff --git a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt index 3a3be83f..a5a8cf80 100644 --- a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt +++ b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt @@ -45,15 +45,13 @@ private sealed interface NewsUiState { private const val SummaryMaxLength = 180 -private data class AnnouncementSummary(val text: String, val hasMore: Boolean) - -private fun summarizeAnnouncementContent(content: String): AnnouncementSummary { +private fun summarizeAnnouncementContent(content: String): String { val normalized = content.replace(Regex("\\s+"), " ").trim() if (normalized.length <= SummaryMaxLength) { - return AnnouncementSummary(text = normalized, hasMore = false) + return normalized } val truncated = normalized.substring(0, SummaryMaxLength - 1).trim() - return AnnouncementSummary(text = "$truncated…", hasMore = true) + return "$truncated…" } @Composable @@ -254,7 +252,7 @@ private fun AnnouncementList( ) Text( - text = summary.text, + text = summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/web/e2e/news-fallback.spec.js b/web/e2e/news-fallback.spec.js index 611de47f..5451117a 100644 --- a/web/e2e/news-fallback.spec.js +++ b/web/e2e/news-fallback.spec.js @@ -1,11 +1,13 @@ const { test, expect } = require('@playwright/test'); const { resetDatabase } = require('./helpers/db'); +const CACHED_ANNOUNCEMENTS_KEY = 'neph.publicAnnouncements.cache.v1'; + test.beforeEach(async () => { await resetDatabase(); }); -test('shows demo announcements with retry context when news API is unavailable', async ({ page }) => { +test('shows explicit error state when news API is unavailable and no cache exists', async ({ page }) => { await page.route('**/api/announcements**', async (route) => { await route.fulfill({ status: 503, @@ -19,9 +21,49 @@ test('shows demo announcements with retry context when news API is unavailable', await page.goto('/news'); - await expect(page.getByText('Using fallback news')).toBeVisible(); - await expect(page.getByText(/Announcements could not be refreshed/)).toBeVisible(); + await expect(page.getByText("Couldn't load announcements")).toBeVisible(); + await expect(page.getByText(/Could not load announcements/)).toBeVisible(); await expect(page.getByRole('button', { name: 'Retry news' })).toBeVisible(); + await expect(page.getByText('No announcements could be loaded right now.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Know your nearest gathering area' })).toHaveCount(0); +}); + +test('shows cached announcements with warning when news API is unavailable', async ({ page }) => { + await page.addInitScript((cacheKey) => { + window.localStorage.setItem( + cacheKey, + JSON.stringify({ + announcements: [ + { + id: 'cached_announcement_1', + adminId: 'admin-cache', + title: 'Cached Announcement Title', + content: + 'Cached announcement body content to verify warning-mode rendering when API is unavailable.', + createdAt: '2026-05-10T09:00:00.000Z', + }, + ], + savedAt: '2026-05-10T09:05:00.000Z', + }) + ); + }, CACHED_ANNOUNCEMENTS_KEY); + + await page.route('**/api/announcements**', async (route) => { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + code: 'ANNOUNCEMENTS_UNAVAILABLE', + message: 'Announcements service is unavailable', + }), + }); + }); + + await page.goto('/news'); + + await expect(page.getByText('Using cached announcements')).toBeVisible(); + await expect(page.getByText(/Showing cached announcements instead/)).toBeVisible(); await expect(page.getByText('Last updated:')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Know your nearest gathering area' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Cached Announcement Title' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry news' })).toBeVisible(); }); From f66dc64ef86dee23ec9f673aa256f03f39c17995 Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:38:26 +0300 Subject: [PATCH 18/19] fix(android-auth): replace heart icon with app logo on login/signup --- .../features/auth/presentation/LoginScreen.kt | 7 ++----- .../features/auth/presentation/SignupScreen.kt | 7 ++----- .../com/neph/ui/components/display/BrandLogo.kt | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/auth/presentation/LoginScreen.kt b/android/app/src/main/java/com/neph/features/auth/presentation/LoginScreen.kt index feb4f170..c086e6b6 100644 --- a/android/app/src/main/java/com/neph/features/auth/presentation/LoginScreen.kt +++ b/android/app/src/main/java/com/neph/features/auth/presentation/LoginScreen.kt @@ -43,7 +43,7 @@ import com.neph.features.auth.util.isValidEmail import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.buttons.TextActionButton -import com.neph.ui.components.display.BrandLogo +import com.neph.ui.components.display.AuthHeaderAppLogo import com.neph.ui.components.display.Divider import com.neph.ui.components.display.HelperText import com.neph.ui.components.inputs.AppTextField @@ -212,10 +212,7 @@ fun LoginScreen( title = "Welcome back", subtitle = "Log in to manage your emergency information and stay ready.", logoContent = { - BrandLogo( - size = 64.dp, - showWordmark = false - ) + AuthHeaderAppLogo(size = 64.dp) }, footerContent = { AuthFooterLinks( diff --git a/android/app/src/main/java/com/neph/features/auth/presentation/SignupScreen.kt b/android/app/src/main/java/com/neph/features/auth/presentation/SignupScreen.kt index fa138500..ded41ccf 100644 --- a/android/app/src/main/java/com/neph/features/auth/presentation/SignupScreen.kt +++ b/android/app/src/main/java/com/neph/features/auth/presentation/SignupScreen.kt @@ -43,7 +43,7 @@ import com.neph.features.auth.presentation.components.SocialAuthMode import com.neph.features.auth.util.isValidEmail import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton -import com.neph.ui.components.display.BrandLogo +import com.neph.ui.components.display.AuthHeaderAppLogo import com.neph.ui.components.display.Divider import com.neph.ui.components.display.HelperText import com.neph.ui.components.inputs.AppTextField @@ -260,10 +260,7 @@ fun SignupScreen( title = "Create Account", subtitle = "Set up your account and get ready before emergencies happen.", logoContent = { - BrandLogo( - size = 64.dp, - showWordmark = false - ) + AuthHeaderAppLogo(size = 64.dp) }, footerContent = { AuthFooterLinks( diff --git a/android/app/src/main/java/com/neph/ui/components/display/BrandLogo.kt b/android/app/src/main/java/com/neph/ui/components/display/BrandLogo.kt index ac4ce726..1d1c8c32 100644 --- a/android/app/src/main/java/com/neph/ui/components/display/BrandLogo.kt +++ b/android/app/src/main/java/com/neph/ui/components/display/BrandLogo.kt @@ -1,5 +1,6 @@ package com.neph.ui.components.display +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,9 +15,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.neph.R import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephShapeTokens @@ -113,3 +117,16 @@ fun BrandLogoCompact( ) } } + +@Composable +fun AuthHeaderAppLogo( + modifier: Modifier = Modifier, + size: Dp = 64.dp +) { + Image( + painter = painterResource(id = R.mipmap.ic_launcher_round), + contentDescription = "NEPH", + modifier = modifier.size(size), + contentScale = ContentScale.Fit + ) +} From 0ceb1729d888a79cd1bfe3a3b30831ce554a2854 Mon Sep 17 00:00:00 2001 From: erinc00 Date: Tue, 12 May 2026 14:40:12 +0300 Subject: [PATCH 19/19] fix(android-auth): use app logo on welcome screen --- .../com/neph/features/auth/presentation/WelcomeScreen.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/neph/features/auth/presentation/WelcomeScreen.kt b/android/app/src/main/java/com/neph/features/auth/presentation/WelcomeScreen.kt index 899a94d5..b863ebe3 100644 --- a/android/app/src/main/java/com/neph/features/auth/presentation/WelcomeScreen.kt +++ b/android/app/src/main/java/com/neph/features/auth/presentation/WelcomeScreen.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.unit.dp import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.buttons.TextActionButton -import com.neph.ui.components.display.BrandLogo +import com.neph.ui.components.display.AuthHeaderAppLogo import com.neph.ui.layout.AuthScaffold import com.neph.ui.theme.LocalNephSpacing @@ -23,10 +23,7 @@ fun WelcomeScreen( title = "Welcome to NEPH", subtitle = "Prepare, connect, and stay ready with your neighborhood emergency hub.", logoContent = { - BrandLogo( - size = 72.dp, - showWordmark = false - ) + AuthHeaderAppLogo(size = 72.dp) } ) { Column(