feat(fallback-geopoint): first draft

This commit is contained in:
devthejo 2026-03-14 17:05:35 +01:00
parent 7aca757bd7
commit b99aef1e36
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
24 changed files with 1340 additions and 106 deletions

View 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
}
}
`;

View 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,
},
}));

View 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 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;

View file

@ -66,7 +66,7 @@ const HeroMode = () => {
});
const handleNext = useCallback(() => {
permissionWizardActions.setCurrentStep("success");
permissionWizardActions.setCurrentStep("fallbackLocation");
}, []);
const handleSkip = useCallback(() => {

View file

@ -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>

View file

@ -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;

View 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;
}
}

View 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 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,
},
}));

View file

@ -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>

View file

@ -8,6 +8,8 @@ export const DEVICE_PARAMS_SUBSCRIPTION = gql`
radiusAll
notificationAlertLevel
preferredEmergencyCall
fallbackLocation
fallbackLocationLabel
}
}
`;

View 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 []
}
}

View 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",
}

View file

@ -7,4 +7,5 @@ module.exports = {
GEOCODE_ALERT: "geocodeAlert",
RELATIVE_ALERT: "relativeAlert",
USEFUL_PLACES_PUBLISH: "usefulPlacesPublish",
FALLBACK_LOCATION_SYNC: "fallbackLocationSync",
}

View file

@ -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,
]
}

View file

@ -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

View file

@ -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,
]
}

View file

@ -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

View file

@ -11,4 +11,5 @@ module.exports = {
ALERT_CLOSE: "alertClose",
ALERT_KEEP_OPEN: "alertKeepOpen",
ALERT_REOPEN: "alertReopen",
FALLBACK_LOCATION_SYNC: "fallbackLocationSync",
}

View file

@ -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

View file

@ -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";

View file

@ -0,0 +1,2 @@
alter table "public"."device" add column "fallback_location" geography;
alter table "public"."device" add column "fallback_location_label" text;

View file

@ -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(

View 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"
)
}
}
}

View file

@ -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")
}