feat(fallback-geopoint): first draft
This commit is contained in:
parent
7aca757bd7
commit
b99aef1e36
24 changed files with 1340 additions and 106 deletions
21
app/src/containers/FallbackLocationPicker/gql.js
Normal file
21
app/src/containers/FallbackLocationPicker/gql.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { gql } from "@apollo/client";
|
||||
|
||||
export const QUERY_NOMINATIM_SEARCH = gql`
|
||||
query nominatimSearch($q: String!) {
|
||||
getOneInfoNominatimSearch(q: $q) {
|
||||
results {
|
||||
latitude
|
||||
longitude
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_NOMINATIM_REVERSE = gql`
|
||||
query nominatimReverse($latitude: Float!, $longitude: Float!) {
|
||||
getOneInfoNominatim(lat: $latitude, lon: $longitude) {
|
||||
address
|
||||
}
|
||||
}
|
||||
`;
|
||||
418
app/src/containers/FallbackLocationPicker/index.js
Normal file
418
app/src/containers/FallbackLocationPicker/index.js
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import Maplibre from "@maplibre/maplibre-react-native";
|
||||
import { useLazyQuery } from "@apollo/client";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { createStyles, useTheme } from "~/theme";
|
||||
import Text from "~/components/Text";
|
||||
import CustomButton from "~/components/CustomButton";
|
||||
import MapView from "~/containers/Map/MapView";
|
||||
import useLocation from "~/hooks/useLocation";
|
||||
|
||||
import { QUERY_NOMINATIM_SEARCH, QUERY_NOMINATIM_REVERSE } from "./gql";
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
const DEFAULT_ZOOM = 14;
|
||||
|
||||
export default function FallbackLocationPicker({
|
||||
initialCoordinates,
|
||||
initialLabel,
|
||||
onSave,
|
||||
onClear,
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const styles = useStyles();
|
||||
const mapRef = useRef(null);
|
||||
const cameraRef = useRef(null);
|
||||
const debounceRef = useRef(null);
|
||||
const onSaveRef = useRef(onSave);
|
||||
const selectedCoordsRef = useRef(initialCoordinates || null);
|
||||
const reverseGeocodeIdRef = useRef(0);
|
||||
|
||||
const { coords } = useLocation();
|
||||
|
||||
// Keep refs in sync
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
// Selected coordinates [lon, lat]
|
||||
const [selectedCoords, setSelectedCoords] = useState(
|
||||
initialCoordinates || null,
|
||||
);
|
||||
const [addressLabel, setAddressLabel] = useState(initialLabel || "");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
const [searchNominatim, { loading: searchLoading }] = useLazyQuery(
|
||||
QUERY_NOMINATIM_SEARCH,
|
||||
{
|
||||
fetchPolicy: "network-only",
|
||||
onCompleted: (data) => {
|
||||
const results = data?.getOneInfoNominatimSearch?.results || [];
|
||||
setSearchResults(results);
|
||||
setShowResults(results.length > 0);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [reverseGeocodeQuery] = useLazyQuery(QUERY_NOMINATIM_REVERSE, {
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const reverseGeocode = useCallback(
|
||||
async (lat, lon) => {
|
||||
const requestId = ++reverseGeocodeIdRef.current;
|
||||
try {
|
||||
const { data } = await reverseGeocodeQuery({
|
||||
variables: { latitude: lat, longitude: lon },
|
||||
});
|
||||
if (requestId !== reverseGeocodeIdRef.current) return; // stale response
|
||||
const address = data?.getOneInfoNominatim?.address;
|
||||
if (address) {
|
||||
setAddressLabel(address);
|
||||
if (onSaveRef.current && selectedCoordsRef.current) {
|
||||
onSaveRef.current(selectedCoordsRef.current, address);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// reverse geocode failed silently — user can still save by coordinates
|
||||
}
|
||||
},
|
||||
[reverseGeocodeQuery],
|
||||
);
|
||||
|
||||
const handleSearchTextChange = useCallback(
|
||||
(text) => {
|
||||
setSearchText(text);
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
if (text.trim().length < 3) {
|
||||
setSearchResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
searchNominatim({ variables: { q: text.trim() } });
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[searchNominatim],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const moveCameraTo = useCallback((lon, lat) => {
|
||||
if (cameraRef.current) {
|
||||
cameraRef.current.setCamera({
|
||||
centerCoordinate: [lon, lat],
|
||||
zoomLevel: DEFAULT_ZOOM,
|
||||
animationDuration: 500,
|
||||
animationMode: "flyTo",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectLocation = useCallback(
|
||||
(lon, lat, label) => {
|
||||
const newCoords = [lon, lat];
|
||||
setSelectedCoords(newCoords);
|
||||
selectedCoordsRef.current = newCoords;
|
||||
if (label) {
|
||||
setAddressLabel(label);
|
||||
if (onSaveRef.current) {
|
||||
onSaveRef.current(newCoords, label);
|
||||
}
|
||||
} else {
|
||||
// Reverse geocode will call onSave when it completes (stale responses are discarded)
|
||||
reverseGeocode(lat, lon);
|
||||
}
|
||||
moveCameraTo(lon, lat);
|
||||
setShowResults(false);
|
||||
setSearchText("");
|
||||
},
|
||||
[reverseGeocode, moveCameraTo],
|
||||
);
|
||||
|
||||
const handleSearchResultPress = useCallback(
|
||||
(result) => {
|
||||
selectLocation(result.longitude, result.latitude, result.displayName);
|
||||
},
|
||||
[selectLocation],
|
||||
);
|
||||
|
||||
const handleMapPress = useCallback(
|
||||
(event) => {
|
||||
const { geometry } = event;
|
||||
if (geometry && geometry.coordinates) {
|
||||
const [lon, lat] = geometry.coordinates;
|
||||
selectLocation(lon, lat, null);
|
||||
}
|
||||
},
|
||||
[selectLocation],
|
||||
);
|
||||
|
||||
const handleUseCurrentLocation = useCallback(() => {
|
||||
if (coords.latitude && coords.longitude) {
|
||||
selectLocation(coords.longitude, coords.latitude, null);
|
||||
}
|
||||
}, [coords, selectLocation]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSelectedCoords(null);
|
||||
setAddressLabel("");
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}, [onClear]);
|
||||
|
||||
const initialCameraCenter =
|
||||
selectedCoords ||
|
||||
(coords.latitude && coords.longitude
|
||||
? [coords.longitude, coords.latitude]
|
||||
: [2.3522, 48.8566]); // Default: Paris
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<View style={styles.searchInputContainer}>
|
||||
<Ionicons
|
||||
name="search"
|
||||
size={20}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput, { color: theme.colors.onSurface }]}
|
||||
placeholder="Rechercher une adresse..."
|
||||
placeholderTextColor={theme.colors.onSurfaceVariant}
|
||||
value={searchText}
|
||||
onChangeText={handleSearchTextChange}
|
||||
returnKeyType="search"
|
||||
accessibilityLabel="Rechercher une adresse"
|
||||
accessibilityHint="Saisissez une adresse pour la rechercher sur la carte"
|
||||
/>
|
||||
{searchLoading && (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Search results */}
|
||||
{showResults && (
|
||||
<View
|
||||
style={[
|
||||
styles.resultsContainer,
|
||||
{ backgroundColor: theme.colors.surface },
|
||||
]}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
key={`${result.latitude}-${result.longitude}-${index}`}
|
||||
style={[
|
||||
styles.resultItem,
|
||||
index < searchResults.length - 1 && styles.resultItemBorder,
|
||||
]}
|
||||
onPress={() => handleSearchResultPress(result)}
|
||||
>
|
||||
<Ionicons
|
||||
name="location-outline"
|
||||
size={18}
|
||||
color={theme.colors.primary}
|
||||
style={styles.resultIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.resultText, { color: theme.colors.onSurface }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{result.displayName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Map */}
|
||||
<View style={styles.mapContainer}>
|
||||
<MapView
|
||||
mapRef={mapRef}
|
||||
onPress={handleMapPress}
|
||||
compassViewPosition={1}
|
||||
>
|
||||
<Maplibre.Camera
|
||||
ref={cameraRef}
|
||||
defaultSettings={{
|
||||
centerCoordinate: initialCameraCenter,
|
||||
zoomLevel: DEFAULT_ZOOM,
|
||||
}}
|
||||
/>
|
||||
{selectedCoords && (
|
||||
<Maplibre.ShapeSource
|
||||
id="fallback_location_source"
|
||||
shape={{
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: selectedCoords,
|
||||
},
|
||||
properties: {},
|
||||
}}
|
||||
>
|
||||
<Maplibre.CircleLayer
|
||||
id="fallback_location_circle"
|
||||
style={{
|
||||
circleRadius: 12,
|
||||
circleColor: theme.colors.primary,
|
||||
circleOpacity: 0.9,
|
||||
circleStrokeWidth: 3,
|
||||
circleStrokeColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</Maplibre.ShapeSource>
|
||||
)}
|
||||
</MapView>
|
||||
</View>
|
||||
|
||||
{/* Address label */}
|
||||
{addressLabel ? (
|
||||
<View style={styles.addressContainer}>
|
||||
<Ionicons
|
||||
name="location"
|
||||
size={18}
|
||||
color={theme.colors.primary}
|
||||
style={styles.addressIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.addressText,
|
||||
{ color: theme.colors.onSurfaceVariant },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{addressLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Use current location button */}
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={handleUseCurrentLocation}
|
||||
disabled={!coords.latitude || !coords.longitude}
|
||||
style={styles.locationButton}
|
||||
icon="crosshairs-gps"
|
||||
>
|
||||
Utiliser ma position actuelle
|
||||
</CustomButton>
|
||||
|
||||
{/* Clear button (only if a location is set and onClear is provided) */}
|
||||
{selectedCoords && onClear && (
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={handleClear}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
Supprimer la position
|
||||
</CustomButton>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||
container: {
|
||||
width: "100%",
|
||||
},
|
||||
searchContainer: {
|
||||
marginBottom: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
height: 44,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 44,
|
||||
},
|
||||
resultsContainer: {
|
||||
position: "absolute",
|
||||
top: 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderRadius: 8,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
maxHeight: 200,
|
||||
overflow: "hidden",
|
||||
},
|
||||
resultItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
resultItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.surfaceVariant,
|
||||
},
|
||||
resultIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
resultText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
mapContainer: {
|
||||
height: 250,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: 10,
|
||||
},
|
||||
addressContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
addressIcon: {
|
||||
marginRight: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
addressText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
locationButton: {
|
||||
marginTop: 5,
|
||||
},
|
||||
clearButton: {
|
||||
marginTop: 5,
|
||||
},
|
||||
}));
|
||||
165
app/src/containers/PermissionWizard/FallbackLocation.js
Normal file
165
app/src/containers/PermissionWizard/FallbackLocation.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View, ScrollView } from "react-native";
|
||||
import { Title } from "react-native-paper";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { permissionWizardActions } from "~/stores";
|
||||
import { createStyles, useTheme } from "~/theme";
|
||||
import Text from "~/components/Text";
|
||||
import CustomButton from "~/components/CustomButton";
|
||||
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
|
||||
import FallbackLocationPicker from "~/containers/FallbackLocationPicker";
|
||||
import { useToast } from "~/lib/toast-notifications";
|
||||
import { saveFallbackLocation } from "~/network/fallbackLocationSync";
|
||||
|
||||
const FallbackLocation = () => {
|
||||
const theme = useTheme();
|
||||
const styles = useStyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const toast = useToast();
|
||||
const titleRef = useRef(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locationSelected, setLocationSelected] = useState(false);
|
||||
const [pendingCoords, setPendingCoords] = useState(null);
|
||||
const [pendingLabel, setPendingLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setA11yFocusAfterInteractions(titleRef);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
permissionWizardActions.setCurrentStep("success");
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback((coordinates, label) => {
|
||||
setPendingCoords(coordinates);
|
||||
setPendingLabel(label);
|
||||
setLocationSelected(true);
|
||||
}, []);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (pendingCoords) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveFallbackLocation(pendingCoords, pendingLabel);
|
||||
} catch (error) {
|
||||
setSaving(false);
|
||||
toast.show(
|
||||
"Erreur lors de l'enregistrement de la position. Veuillez réessayer.",
|
||||
{ type: "danger" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
handleNext();
|
||||
}, [pendingCoords, pendingLabel, handleNext, toast]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="home" size={60} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Title
|
||||
ref={titleRef}
|
||||
accessibilityRole="header"
|
||||
style={[styles.title, { color: theme.colors.primary }]}
|
||||
>
|
||||
Ma position habituelle
|
||||
</Title>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{ color: theme.colors.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
Définissez l'endroit où vous vous trouvez habituellement (domicile,
|
||||
travail...).{"\n\n"}Si votre localisation n'est pas mise à jour
|
||||
pendant une longue période, cette position sera utilisée pour
|
||||
continuer à vous alerter des urgences à proximité.
|
||||
</Text>
|
||||
|
||||
<FallbackLocationPicker
|
||||
initialCoordinates={null}
|
||||
initialLabel={null}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<CustomButton
|
||||
mode="contained"
|
||||
onPress={handleContinue}
|
||||
loading={saving}
|
||||
disabled={saving || !locationSelected}
|
||||
>
|
||||
Enregistrer et continuer
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={handleNext}
|
||||
disabled={saving}
|
||||
>
|
||||
Passer cette étape
|
||||
</CustomButton>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 10,
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: 60,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 20,
|
||||
gap: 10,
|
||||
},
|
||||
}));
|
||||
|
||||
export default FallbackLocation;
|
||||
|
|
@ -66,7 +66,7 @@ const HeroMode = () => {
|
|||
});
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
permissionWizardActions.setCurrentStep("success");
|
||||
permissionWizardActions.setCurrentStep("fallbackLocation");
|
||||
}, []);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ const SkipInfo = () => {
|
|||
setA11yFocusAfterInteractions(titleRef);
|
||||
}, []);
|
||||
|
||||
const handleFinish = () => {
|
||||
permissionWizardActions.setCompleted(true);
|
||||
const handleNext = () => {
|
||||
permissionWizardActions.setCurrentStep("fallbackLocation");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -111,8 +111,8 @@ const SkipInfo = () => {
|
|||
</Text>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<CustomButton mode="contained" onPress={handleFinish}>
|
||||
C'est noté, à bientôt !
|
||||
<CustomButton mode="contained" onPress={handleNext}>
|
||||
Continuer
|
||||
</CustomButton>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useTheme } from "~/theme";
|
|||
|
||||
import Welcome from "./Welcome";
|
||||
import HeroMode from "./HeroMode";
|
||||
import FallbackLocation from "./FallbackLocation";
|
||||
import Success from "./Success";
|
||||
import SkipInfo from "./SkipInfo";
|
||||
|
||||
|
|
@ -21,6 +22,9 @@ export default function PermissionWizard({ visible }) {
|
|||
case "tracking":
|
||||
StepComponent = HeroMode;
|
||||
break;
|
||||
case "fallbackLocation":
|
||||
StepComponent = FallbackLocation;
|
||||
break;
|
||||
case "success":
|
||||
StepComponent = Success;
|
||||
break;
|
||||
|
|
|
|||
50
app/src/network/fallbackLocationSync.js
Normal file
50
app/src/network/fallbackLocationSync.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import ky from "ky";
|
||||
import { getAuthState } from "~/stores";
|
||||
import { setBearerHeader } from "./headers";
|
||||
import env from "~/env";
|
||||
|
||||
import { createLogger } from "~/lib/logger";
|
||||
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
||||
|
||||
const logger = createLogger({
|
||||
module: NETWORK_SCOPES.HTTP,
|
||||
feature: "fallback-location-sync",
|
||||
});
|
||||
|
||||
function getUrl() {
|
||||
return env.GEOLOC_SYNC_URL.replace(/\/[^/]*$/, "/fallback-sync");
|
||||
}
|
||||
|
||||
export async function saveFallbackLocation(coordinates, label) {
|
||||
const url = getUrl();
|
||||
const headers = setBearerHeader({}, getAuthState().userToken);
|
||||
|
||||
try {
|
||||
await ky.post(url, {
|
||||
headers,
|
||||
json: { coordinates, label: label || null },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to save fallback location", {
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFallbackLocation() {
|
||||
const url = getUrl();
|
||||
const headers = setBearerHeader({}, getAuthState().userToken);
|
||||
|
||||
try {
|
||||
await ky.post(url, {
|
||||
headers,
|
||||
json: { coordinates: null, label: null },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to clear fallback location", {
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
208
app/src/scenes/Params/FallbackLocation.js
Normal file
208
app/src/scenes/Params/FallbackLocation.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Title } from "react-native-paper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { createStyles, useTheme } from "~/theme";
|
||||
import Text from "~/components/Text";
|
||||
import CustomButton from "~/components/CustomButton";
|
||||
import FallbackLocationPicker from "~/containers/FallbackLocationPicker";
|
||||
import {
|
||||
saveFallbackLocation,
|
||||
clearFallbackLocation,
|
||||
} from "~/network/fallbackLocationSync";
|
||||
import { useToast } from "~/lib/toast-notifications";
|
||||
|
||||
export default function ParamsFallbackLocation({ data }) {
|
||||
const styles = useStyles();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pendingCoords, setPendingCoords] = useState(null);
|
||||
const [pendingLabel, setPendingLabel] = useState("");
|
||||
|
||||
const deviceData = data?.selectOneDevice;
|
||||
const currentLocation = deviceData?.fallbackLocation;
|
||||
const currentLabel = deviceData?.fallbackLocationLabel;
|
||||
|
||||
// Extract coordinates from GeoJSON geography
|
||||
const currentCoords = currentLocation?.coordinates || null;
|
||||
|
||||
const handleSelect = useCallback((coordinates, label) => {
|
||||
setPendingCoords(coordinates);
|
||||
setPendingLabel(label);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
if (!pendingCoords) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveFallbackLocation(pendingCoords, pendingLabel);
|
||||
setEditing(false);
|
||||
setPendingCoords(null);
|
||||
setPendingLabel("");
|
||||
} catch (error) {
|
||||
setSaving(false);
|
||||
toast.show(
|
||||
"Erreur lors de l'enregistrement de la position. Veuillez réessayer.",
|
||||
{ type: "danger" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
}, [pendingCoords, pendingLabel, toast]);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await clearFallbackLocation();
|
||||
setEditing(false);
|
||||
setPendingCoords(null);
|
||||
setPendingLabel("");
|
||||
} catch (error) {
|
||||
setSaving(false);
|
||||
toast.show(
|
||||
"Erreur lors de la suppression de la position. Veuillez réessayer.",
|
||||
{ type: "danger" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
}, [toast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title style={styles.title}>Ma position habituelle</Title>
|
||||
<Text style={styles.description}>
|
||||
Définissez l'endroit où vous vous trouvez habituellement. Cette position
|
||||
sera utilisée si votre localisation n'est pas mise à jour pendant une
|
||||
longue période.
|
||||
</Text>
|
||||
|
||||
{!editing && currentCoords ? (
|
||||
<View style={styles.currentLocationContainer}>
|
||||
<View style={styles.currentLocationInfo}>
|
||||
<Ionicons
|
||||
name="location"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
style={styles.locationIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.currentLocationLabel,
|
||||
{ color: theme.colors.onSurface },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{currentLabel || "Position définie"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.currentLocationActions}>
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={() => setEditing(true)}
|
||||
disabled={saving}
|
||||
style={styles.actionButton}
|
||||
>
|
||||
Modifier
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={handleClear}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
style={styles.actionButton}
|
||||
>
|
||||
Supprimer
|
||||
</CustomButton>
|
||||
</View>
|
||||
</View>
|
||||
) : !editing ? (
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={() => setEditing(true)}
|
||||
icon="map-marker-plus"
|
||||
>
|
||||
Définir ma position
|
||||
</CustomButton>
|
||||
) : (
|
||||
<View style={styles.pickerContainer}>
|
||||
<FallbackLocationPicker
|
||||
initialCoordinates={currentCoords}
|
||||
initialLabel={currentLabel}
|
||||
onSave={handleSelect}
|
||||
onClear={currentCoords ? handleClear : undefined}
|
||||
/>
|
||||
<CustomButton
|
||||
mode="contained"
|
||||
onPress={handleConfirmSave}
|
||||
loading={saving}
|
||||
disabled={saving || !pendingCoords}
|
||||
style={styles.saveButton}
|
||||
>
|
||||
Enregistrer
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
mode="outlined"
|
||||
onPress={() => setEditing(false)}
|
||||
disabled={saving}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Annuler
|
||||
</CustomButton>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginVertical: 15,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.onSurfaceVariant,
|
||||
marginBottom: 15,
|
||||
},
|
||||
currentLocationContainer: {
|
||||
backgroundColor: colors.surfaceVariant,
|
||||
borderRadius: 8,
|
||||
padding: 15,
|
||||
},
|
||||
currentLocationInfo: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 10,
|
||||
},
|
||||
locationIcon: {
|
||||
marginRight: 10,
|
||||
marginTop: 2,
|
||||
},
|
||||
currentLocationLabel: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
currentLocationActions: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
pickerContainer: {
|
||||
marginTop: 5,
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
}));
|
||||
|
|
@ -5,6 +5,7 @@ import ParamsNotifications from "./Notifications";
|
|||
import ParamsRadius from "./Radius";
|
||||
import ParamsEmergencyCall from "./EmergencyCall";
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
import ParamsFallbackLocation from "./FallbackLocation";
|
||||
import Permissions from "./Permissions";
|
||||
import SentryOptOut from "./SentryOptOut";
|
||||
import { useRoute, useFocusEffect } from "@react-navigation/native";
|
||||
|
|
@ -39,6 +40,9 @@ export default function ParamsView({ data }) {
|
|||
<View style={styles.section}>
|
||||
<ThemeSwitcher />
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
<ParamsFallbackLocation data={data} />
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
<ParamsEmergencyCall data={data} />
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export const DEVICE_PARAMS_SUBSCRIPTION = gql`
|
|||
radiusAll
|
||||
notificationAlertLevel
|
||||
preferredEmergencyCall
|
||||
fallbackLocation
|
||||
fallbackLocationLabel
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
50
libs/common/external-api/nominatim-search.js
Normal file
50
libs/common/external-api/nominatim-search.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const { default: axios } = require("axios")
|
||||
const qs = require("qs")
|
||||
const { ctx } = require("@modjo/core")
|
||||
// see https://nominatim.org/release-docs/latest/api/Search/
|
||||
|
||||
module.exports = async function nominatimSearch(query, options = {}) {
|
||||
const config = ctx.get("config.project")
|
||||
const { nominatimUrl } = config
|
||||
|
||||
const logger = ctx.require("logger")
|
||||
|
||||
if (!nominatimUrl) {
|
||||
logger.error("nominatimUrl is not configured in project config")
|
||||
return []
|
||||
}
|
||||
|
||||
const search = qs.stringify({
|
||||
format: "json",
|
||||
addressdetails: 1,
|
||||
limit: 5,
|
||||
...options,
|
||||
q: query,
|
||||
})
|
||||
|
||||
const url = `${nominatimUrl}/search?${search}`
|
||||
try {
|
||||
const res = await axios.request({
|
||||
url,
|
||||
method: "get",
|
||||
headers: {
|
||||
"accept-language": "fr",
|
||||
},
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
logger.error(
|
||||
{ res, url },
|
||||
"nominatim server did not answer with a HTTP code 200"
|
||||
)
|
||||
}
|
||||
return res.data || []
|
||||
} catch (e) {
|
||||
if (e.response?.data)
|
||||
logger.error(
|
||||
{ responseData: e.response.data, error: e },
|
||||
"nominatim search failed"
|
||||
)
|
||||
else logger.error({ url, error: e }, "nominatim search failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
6
libs/common/geodata/redis-keys.js
Normal file
6
libs/common/geodata/redis-keys.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
COLDGEODATA_DEVICE_KEY_PREFIX: "device:geodata:",
|
||||
COLDGEODATA_OLD_KEY_PREFIX: "old:device:geodata:",
|
||||
COLDGEODATA_NOTIFIED_KEY_PREFIX: "notified:device:geodata:",
|
||||
HOTGEODATA_KEY: "device",
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ module.exports = {
|
|||
GEOCODE_ALERT: "geocodeAlert",
|
||||
RELATIVE_ALERT: "relativeAlert",
|
||||
USEFUL_PLACES_PUBLISH: "usefulPlacesPublish",
|
||||
FALLBACK_LOCATION_SYNC: "fallbackLocationSync",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
const { ctx } = require("@modjo/core")
|
||||
const { reqCtx } = require("@modjo/express/ctx")
|
||||
|
||||
const tasks = require("~/tasks")
|
||||
|
||||
module.exports = function ({ services: { middlewareRateLimiterIpUser } }) {
|
||||
const { addTask } = ctx.require("amqp")
|
||||
const sql = ctx.require("postgres")
|
||||
|
||||
async function addOneGeolocFallbackSync(req) {
|
||||
const logger = ctx.require("logger")
|
||||
const session = reqCtx.get("session")
|
||||
const { deviceId } = session
|
||||
const { coordinates, label } = req.body
|
||||
|
||||
logger.debug({ action: "fallback-sync", deviceId })
|
||||
|
||||
if (coordinates) {
|
||||
const [lon, lat] = coordinates
|
||||
if (lon < -180 || lon > 180 || lat < -90 || lat > 90) {
|
||||
const error = new Error(
|
||||
"Invalid coordinates: longitude must be between -180 and 180, latitude between -90 and 90"
|
||||
)
|
||||
error.status = 400
|
||||
throw error
|
||||
}
|
||||
await sql`
|
||||
UPDATE
|
||||
"device"
|
||||
SET
|
||||
"fallback_location" = ST_SetSRID (ST_MakePoint (${lon}, ${lat}), 4326)::geography,
|
||||
"fallback_location_label" = ${label || null}
|
||||
WHERE
|
||||
"id" = ${deviceId}
|
||||
`
|
||||
} else {
|
||||
await sql`
|
||||
UPDATE
|
||||
"device"
|
||||
SET
|
||||
"fallback_location" = NULL,
|
||||
"fallback_location_label" = NULL
|
||||
WHERE
|
||||
"id" = ${deviceId}
|
||||
`
|
||||
}
|
||||
|
||||
await addTask(tasks.FALLBACK_LOCATION_SYNC, { deviceId })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return [
|
||||
middlewareRateLimiterIpUser({
|
||||
points: 10,
|
||||
duration: 60,
|
||||
}),
|
||||
addOneGeolocFallbackSync,
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
x-security:
|
||||
- auth: ["user"]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
coordinates:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: number
|
||||
minItems: 2
|
||||
maxItems: 2
|
||||
description: "[longitude, latitude] or null to clear"
|
||||
label:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 500
|
||||
responses:
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
const nominatimSearch = require("common/external-api/nominatim-search")
|
||||
|
||||
module.exports = function ({ services: { middlewareRateLimiterIpUser } }) {
|
||||
async function getOneInfoNominatimSearch(req) {
|
||||
const { q } = req.query
|
||||
const nominatimResults = await nominatimSearch(q)
|
||||
const results = nominatimResults.map((result) => ({
|
||||
latitude: parseFloat(result.lat),
|
||||
longitude: parseFloat(result.lon),
|
||||
displayName: result.display_name || "",
|
||||
}))
|
||||
return { results }
|
||||
}
|
||||
return [
|
||||
middlewareRateLimiterIpUser({
|
||||
points: 90,
|
||||
duration: 60,
|
||||
}),
|
||||
getOneInfoNominatimSearch,
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
x-security:
|
||||
- auth: ["user"]
|
||||
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
responses:
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
latitude:
|
||||
type: number
|
||||
format: latitude
|
||||
longitude:
|
||||
type: number
|
||||
format: longitude
|
||||
displayName:
|
||||
type: string
|
||||
|
|
@ -11,4 +11,5 @@ module.exports = {
|
|||
ALERT_CLOSE: "alertClose",
|
||||
ALERT_KEEP_OPEN: "alertKeepOpen",
|
||||
ALERT_REOPEN: "alertReopen",
|
||||
FALLBACK_LOCATION_SYNC: "fallbackLocationSync",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ configuration:
|
|||
custom_name: altitudeAccuracy
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
fallback_location:
|
||||
custom_name: fallbackLocation
|
||||
fallback_location_label:
|
||||
custom_name: fallbackLocationLabel
|
||||
fcm_token:
|
||||
custom_name: fcmToken
|
||||
follow_location:
|
||||
|
|
@ -30,6 +34,8 @@ configuration:
|
|||
custom_column_names:
|
||||
altitude_accuracy: altitudeAccuracy
|
||||
created_at: createdAt
|
||||
fallback_location: fallbackLocation
|
||||
fallback_location_label: fallbackLocationLabel
|
||||
fcm_token: fcmToken
|
||||
follow_location: followLocation
|
||||
notification_alert_level: notificationAlertLevel
|
||||
|
|
@ -72,6 +78,8 @@ select_permissions:
|
|||
- altitude
|
||||
- altitude_accuracy
|
||||
- created_at
|
||||
- fallback_location
|
||||
- fallback_location_label
|
||||
- heading
|
||||
- id
|
||||
- location
|
||||
|
|
@ -96,6 +104,8 @@ update_permissions:
|
|||
- accuracy
|
||||
- altitude
|
||||
- altitude_accuracy
|
||||
- fallback_location
|
||||
- fallback_location_label
|
||||
- fcm_token
|
||||
- heading
|
||||
- location
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
alter table "public"."device" drop column if exists "fallback_location";
|
||||
alter table "public"."device" drop column if exists "fallback_location_label";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
alter table "public"."device" add column "fallback_location" geography;
|
||||
alter table "public"."device" add column "fallback_location_label" text;
|
||||
|
|
@ -3,16 +3,20 @@ const { taskCtx } = require("@modjo/microservice-worker/ctx")
|
|||
|
||||
const addNotification = require("~/services/add-notification")
|
||||
|
||||
function createBackgroundGeolocationLostNotification() {
|
||||
function createBackgroundGeolocationLostNotification(hasFallback) {
|
||||
return {
|
||||
data: {
|
||||
action: "background-geolocation-lost",
|
||||
},
|
||||
notification: {
|
||||
title: `Alerte-Secours ne reçoit plus de mises à jour de votre position`,
|
||||
body: `Vous ne pourrez plus recevoir d'alertes de proximité. Vérifiez les paramètres.`,
|
||||
title: hasFallback
|
||||
? `Votre position en temps réel n'est plus à jour`
|
||||
: `Alerte-Secours ne reçoit plus de mises à jour de votre position`,
|
||||
body: hasFallback
|
||||
? `Votre position habituelle sera utilisée comme point de repère. Ouvrez l'application pour reprendre le suivi.`
|
||||
: `Vous ne pourrez plus recevoir d'alertes de proximité. Vérifiez les paramètres.`,
|
||||
channel: "system",
|
||||
priority: "high",
|
||||
priority: hasFallback ? "default" : "high",
|
||||
actionId: "open-background-geolocation-settings",
|
||||
},
|
||||
}
|
||||
|
|
@ -24,7 +28,7 @@ module.exports = async function () {
|
|||
const logger = taskCtx.require("logger")
|
||||
const sql = ctx.require("postgres")
|
||||
|
||||
const { deviceId } = params
|
||||
const { deviceId, hasFallback } = params
|
||||
|
||||
try {
|
||||
// Get the user ID associated with this device
|
||||
|
|
@ -68,7 +72,8 @@ module.exports = async function () {
|
|||
const { fcmToken } = deviceResult[0]
|
||||
|
||||
// Create notification config
|
||||
const notificationConfig = createBackgroundGeolocationLostNotification()
|
||||
const notificationConfig =
|
||||
createBackgroundGeolocationLostNotification(hasFallback)
|
||||
|
||||
// Send notification
|
||||
logger.info(
|
||||
|
|
|
|||
76
services/tasks/src/queues/fallback-location-sync.js
Normal file
76
services/tasks/src/queues/fallback-location-sync.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const { ctx } = require("@modjo/core")
|
||||
|
||||
const {
|
||||
COLDGEODATA_DEVICE_KEY_PREFIX,
|
||||
HOTGEODATA_KEY,
|
||||
} = require("common/geodata/redis-keys")
|
||||
|
||||
module.exports = async function () {
|
||||
const redisHot = ctx.require("redisHotGeodata")
|
||||
const redisCold = ctx.require("kvrocksColdGeodata")
|
||||
const sql = ctx.require("postgres")
|
||||
const logger = ctx.require("logger")
|
||||
|
||||
return async function fallbackLocationSync(params) {
|
||||
const { deviceId } = params
|
||||
|
||||
const coldKey = `${COLDGEODATA_DEVICE_KEY_PREFIX}${deviceId}`
|
||||
const coldData = await redisCold.get(coldKey)
|
||||
|
||||
if (!coldData) {
|
||||
// Device has no cold geodata entry, nothing to sync
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(coldData)
|
||||
|
||||
if (!data.isFallback) {
|
||||
// Device is still actively sending GPS updates, fallback not in use yet
|
||||
return
|
||||
}
|
||||
|
||||
// Device is currently running on fallback, check if fallback location changed or was removed
|
||||
const [fallbackDevice] = await sql`
|
||||
SELECT
|
||||
ST_X ("fallback_location"::geometry) as lon,
|
||||
ST_Y ("fallback_location"::geometry) as lat
|
||||
FROM
|
||||
"device"
|
||||
WHERE
|
||||
"id" = ${parseInt(deviceId, 10)}
|
||||
AND "fallback_location" IS NOT NULL
|
||||
`
|
||||
|
||||
if (fallbackDevice && fallbackDevice.lon && fallbackDevice.lat) {
|
||||
// Fallback location exists (new or updated): sync to hot storage
|
||||
await redisHot.geoadd(
|
||||
HOTGEODATA_KEY,
|
||||
fallbackDevice.lon,
|
||||
fallbackDevice.lat,
|
||||
deviceId
|
||||
)
|
||||
|
||||
data.updatedAt = Math.floor(Date.now() / 1000)
|
||||
data.coordinates = [fallbackDevice.lon, fallbackDevice.lat]
|
||||
await redisCold.set(coldKey, JSON.stringify(data))
|
||||
|
||||
logger.info(
|
||||
{ deviceId },
|
||||
"Synced updated fallback location to hot storage"
|
||||
)
|
||||
} else {
|
||||
// Fallback was removed: remove from hot storage but keep cold key
|
||||
// so the device can recover when GPS resumes (cleanup cron will handle archival)
|
||||
await redisHot.zrem(HOTGEODATA_KEY, deviceId)
|
||||
|
||||
delete data.isFallback
|
||||
delete data.coordinates
|
||||
await redisCold.set(coldKey, JSON.stringify(data))
|
||||
|
||||
logger.info(
|
||||
{ deviceId },
|
||||
"Fallback removed, removed from hot storage, cold key preserved for GPS recovery"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
const async = require("async")
|
||||
const { ctx } = require("@modjo/core")
|
||||
const ms = require("ms")
|
||||
const {
|
||||
COLDGEODATA_DEVICE_KEY_PREFIX,
|
||||
COLDGEODATA_OLD_KEY_PREFIX,
|
||||
COLDGEODATA_NOTIFIED_KEY_PREFIX,
|
||||
HOTGEODATA_KEY,
|
||||
} = require("common/geodata/redis-keys")
|
||||
const cron = require("~/libs/cron")
|
||||
const {
|
||||
// DEVICE_GEODATA_IOS_SILENT_PUSH_AGE,
|
||||
|
|
@ -11,10 +17,6 @@ const tasks = require("~/tasks")
|
|||
|
||||
const CLEANUP_CRON = "0 */4 * * *" // Run every 4 hours
|
||||
const MAX_PARALLEL_PROCESS = 10
|
||||
const COLDGEODATA_DEVICE_KEY_PREFIX = "device:geodata:"
|
||||
const COLDGEODATA_OLD_KEY_PREFIX = "old:device:geodata:"
|
||||
const COLDGEODATA_NOTIFIED_KEY_PREFIX = "notified:device:geodata:"
|
||||
const HOTGEODATA_KEY = "device" // The key where hot geodata is stored
|
||||
// const iosHeartbeatAge = Math.floor(ms(DEVICE_GEODATA_IOS_SILENT_PUSH_AGE) / 1000)
|
||||
const notificationAge = Math.floor(ms(DEVICE_GEODATA_NOTIFICATION_AGE) / 1000) // Convert to seconds
|
||||
const cleanupAge = Math.floor(ms(DEVICE_GEODATA_CLEANUP_AGE) / 1000) // Convert to seconds
|
||||
|
|
@ -24,6 +26,7 @@ module.exports = async function () {
|
|||
const redisCold = ctx.require("kvrocksColdGeodata")
|
||||
const redisHot = ctx.require("redisHotGeodata")
|
||||
const { addTask } = ctx.require("amqp")
|
||||
const sql = ctx.require("postgres")
|
||||
|
||||
return async function geodataCleanupCron() {
|
||||
logger.info("watcher geodataCleanupCron: daemon started")
|
||||
|
|
@ -53,106 +56,171 @@ module.exports = async function () {
|
|||
|
||||
// Process this batch of keys immediately
|
||||
if (keys.length > 0) {
|
||||
// Phase 1: Read cold storage data for all keys in this batch
|
||||
const devicesInfo = []
|
||||
await async.eachLimit(keys, MAX_PARALLEL_PROCESS, async (key) => {
|
||||
const deviceId = key.slice(COLDGEODATA_DEVICE_KEY_PREFIX.length)
|
||||
|
||||
try {
|
||||
// Get device data from cold storage
|
||||
const deviceData = await redisCold.get(key)
|
||||
if (!deviceData) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse stored JSON to get `updatedAt`
|
||||
if (!deviceData) return
|
||||
const data = JSON.parse(deviceData)
|
||||
const age = data.updatedAt ? now - data.updatedAt : Infinity
|
||||
|
||||
if (age > cleanupAge) {
|
||||
try {
|
||||
// Remove from hot storage
|
||||
await redisHot.zrem(HOTGEODATA_KEY, deviceId)
|
||||
|
||||
// Move to cleaned prefix in cold storage and clean notification flag atomically
|
||||
const oldKey = `${COLDGEODATA_OLD_KEY_PREFIX}${deviceId}`
|
||||
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
||||
|
||||
const transaction = redisCold.multi()
|
||||
transaction.rename(key, oldKey)
|
||||
transaction.del(notifiedKey)
|
||||
await transaction.exec()
|
||||
|
||||
logger.debug(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Removed old device data from hot storage and marked as cleaned in cold storage"
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, deviceId },
|
||||
"Error cleaning device data"
|
||||
)
|
||||
}
|
||||
// } else if (age > iosHeartbeatAge) {
|
||||
// try {
|
||||
// await addTask(tasks.IOS_GEOLOCATION_HEARTBEAT_SYNC, {
|
||||
// deviceId,
|
||||
// })
|
||||
|
||||
// logger.info(
|
||||
// { deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
// "Enqueued iOS geolocation heartbeat sync task"
|
||||
// )
|
||||
// } catch (heartbeatError) {
|
||||
// logger.error(
|
||||
// { deviceId, error: heartbeatError },
|
||||
// "Error enqueueing iOS geolocation heartbeat sync task"
|
||||
// )
|
||||
// }
|
||||
} else if (age > notificationAge) {
|
||||
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
||||
|
||||
try {
|
||||
// Check if we've already notified for this device
|
||||
const alreadyNotified = await redisCold.exists(notifiedKey)
|
||||
|
||||
if (!alreadyNotified && isWithinNotificationWindow()) {
|
||||
// Enqueue task to notify user about lost background geolocation
|
||||
try {
|
||||
await addTask(tasks.BACKGROUND_GEOLOCATION_LOST_NOTIFY, {
|
||||
deviceId,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Enqueued background geolocation lost notification task"
|
||||
)
|
||||
} catch (notifError) {
|
||||
logger.error(
|
||||
{ deviceId, error: notifError },
|
||||
"Error enqueueing background geolocation lost notification task"
|
||||
)
|
||||
}
|
||||
// Mark as notified with 48h expiry (cleanup age)
|
||||
await redisCold.set(notifiedKey, "1", "EX", cleanupAge)
|
||||
} else if (
|
||||
!alreadyNotified &&
|
||||
!isWithinNotificationWindow()
|
||||
) {
|
||||
logger.debug(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Skipping notification outside business hours (9-19h)"
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, deviceId },
|
||||
"Error processing notification for device"
|
||||
)
|
||||
}
|
||||
}
|
||||
devicesInfo.push({ key, deviceId, data, age })
|
||||
} catch (error) {
|
||||
logger.error({ error, key }, "Error processing device data")
|
||||
logger.error({ error, key }, "Error reading device data")
|
||||
}
|
||||
})
|
||||
|
||||
// Phase 2: Batch fetch fallback locations for devices that need DB lookup
|
||||
const deviceIdsNeedingLookup = devicesInfo
|
||||
.filter(
|
||||
(d) =>
|
||||
d.age > cleanupAge ||
|
||||
(d.age > notificationAge && !d.data.isFallback)
|
||||
)
|
||||
.map((d) => parseInt(d.deviceId, 10))
|
||||
|
||||
const fallbackMap = new Map()
|
||||
if (deviceIdsNeedingLookup.length > 0) {
|
||||
try {
|
||||
const fallbackResults = await sql`
|
||||
SELECT
|
||||
"id",
|
||||
ST_X ("fallback_location"::geometry) as lon,
|
||||
ST_Y ("fallback_location"::geometry) as lat
|
||||
FROM
|
||||
"device"
|
||||
WHERE
|
||||
"id" = ANY (${deviceIdsNeedingLookup})
|
||||
AND "fallback_location" IS NOT NULL
|
||||
`
|
||||
for (const row of fallbackResults) {
|
||||
fallbackMap.set(row.id, { lon: row.lon, lat: row.lat })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error batch-fetching fallback locations")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Process each device using pre-fetched fallback data
|
||||
await async.eachLimit(
|
||||
devicesInfo,
|
||||
MAX_PARALLEL_PROCESS,
|
||||
async ({ key, deviceId, data, age }) => {
|
||||
try {
|
||||
if (age > cleanupAge) {
|
||||
try {
|
||||
const fallbackDevice = fallbackMap.get(
|
||||
parseInt(deviceId, 10)
|
||||
)
|
||||
|
||||
if (
|
||||
fallbackDevice &&
|
||||
fallbackDevice.lon &&
|
||||
fallbackDevice.lat
|
||||
) {
|
||||
// Replace hot storage with fallback coordinates (always refresh from DB to pick up changes)
|
||||
await redisHot.geoadd(
|
||||
HOTGEODATA_KEY,
|
||||
fallbackDevice.lon,
|
||||
fallbackDevice.lat,
|
||||
deviceId
|
||||
)
|
||||
|
||||
// Refresh updatedAt in cold storage to prevent re-cleanup for another cycle
|
||||
data.updatedAt = Math.floor(Date.now() / 1000)
|
||||
data.isFallback = true
|
||||
data.coordinates = [
|
||||
fallbackDevice.lon,
|
||||
fallbackDevice.lat,
|
||||
]
|
||||
await redisCold.set(key, JSON.stringify(data))
|
||||
|
||||
logger.debug(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Refreshed device fallback location in hot storage"
|
||||
)
|
||||
} else {
|
||||
// No fallback (or fallback removed by user): remove from hot storage and archive cold key
|
||||
await redisHot.zrem(HOTGEODATA_KEY, deviceId)
|
||||
|
||||
const oldKey = `${COLDGEODATA_OLD_KEY_PREFIX}${deviceId}`
|
||||
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
||||
|
||||
// Guard against race with fallback-location-sync queue
|
||||
const coldKeyExists = await redisCold.exists(key)
|
||||
if (coldKeyExists) {
|
||||
const transaction = redisCold.multi()
|
||||
transaction.rename(key, oldKey)
|
||||
transaction.del(notifiedKey)
|
||||
await transaction.exec()
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Removed old device data from hot storage and archived in cold storage"
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, deviceId },
|
||||
"Error cleaning device data"
|
||||
)
|
||||
}
|
||||
} else if (age > notificationAge && !data.isFallback) {
|
||||
// Skip notification for devices already running on fallback location
|
||||
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
||||
|
||||
try {
|
||||
const alreadyNotified = await redisCold.exists(notifiedKey)
|
||||
|
||||
if (!alreadyNotified && isWithinNotificationWindow()) {
|
||||
const hasFallback = fallbackMap.has(
|
||||
parseInt(deviceId, 10)
|
||||
)
|
||||
|
||||
try {
|
||||
await addTask(
|
||||
tasks.BACKGROUND_GEOLOCATION_LOST_NOTIFY,
|
||||
{
|
||||
deviceId,
|
||||
hasFallback,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Enqueued background geolocation lost notification task"
|
||||
)
|
||||
} catch (notifError) {
|
||||
logger.error(
|
||||
{ deviceId, error: notifError },
|
||||
"Error enqueueing background geolocation lost notification task"
|
||||
)
|
||||
}
|
||||
// Mark as notified with expiry matching cleanup age
|
||||
await redisCold.set(notifiedKey, "1", "EX", cleanupAge)
|
||||
} else if (
|
||||
!alreadyNotified &&
|
||||
!isWithinNotificationWindow()
|
||||
) {
|
||||
logger.debug(
|
||||
{ deviceId, age: `${Math.floor(age / 3600)}h` },
|
||||
"Skipping notification outside business hours (9-19h)"
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, deviceId },
|
||||
"Error processing notification for device"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, key }, "Error processing device data")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} while (coldCursor !== "0")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue