feat(train): search for places in the route planner, through Photon (ADR 0030, D50)

A `search` endpoint asks the instance's Photon (compose.search.yaml, Europe's
database from GraphHopper, renewed monthly) for people signed in, limited and
unlogged. The planner gets a field over its map: the places found nearest the
map's centre first, in the app's language; one chosen is the next waypoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 16:20:36 +01:00
commit edd2df79bd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 2184 additions and 7 deletions

View file

@ -28,6 +28,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
import '../firmware/firmware_endpoint.dart' as _id2y0j3h;
import '../map/map_endpoint.dart' as _i35zr6r3;
import '../routing/routing_endpoint.dart' as _ivjydyye;
import '../search/search_endpoint.dart' as _i2f0v2ey;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@ -52,6 +53,12 @@ class Endpoints extends _is.EndpointDispatch {
'routing',
null,
),
'search': _i2f0v2ey.SearchEndpoint()
..initialize(
server,
'search',
null,
),
};
connectors['firmware'] = _is.EndpointConnector(
name: 'firmware',
@ -182,6 +189,53 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['search'] = _is.EndpointConnector(
name: 'search',
endpoint: endpoints['search']!,
methodConnectors: {
'offered': _is.MethodConnector(
name: 'offered',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['search'] as _i2f0v2ey.SearchEndpoint)
.offered(session),
),
'places': _is.MethodConnector(
name: 'places',
params: {
'query': _is.ParameterDescription(
name: 'query',
type: _is.getType<String>(),
nullable: false,
),
'near': _is.ParameterDescription(
name: 'near',
type: _is.getType<_is4pcfub.RoutePoint?>(),
nullable: true,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['search'] as _i2f0v2ey.SearchEndpoint).places(
session,
params['query'],
near: params['near'],
language: params['language'],
),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -15,6 +15,8 @@ import 'package:halkyon_base_server/halkyon_base_server.dart' as _ik8molhd;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:halkyon_train_server/src/generated/routing/route_point.dart'
as _is4pcfub;
import 'package:halkyon_train_server/src/generated/search/place.dart'
as _i6fosizj;
import 'package:serverpod/protocol.dart' as _isp;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
@ -39,6 +41,8 @@ import 'routing/route_point.dart' as _isqtfus6;
import 'routing/route_profile.dart' as _ifl4j5ep;
import 'routing/route_refused.dart' as _ix4aepwk;
import 'routing/route_stretch.dart' as _ic7f85bc;
import 'search/place.dart' as _iadh29yw;
import 'search/search_refused.dart' as _ihbxkwke;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'day/day.dart';
@ -57,6 +61,8 @@ export 'routing/route_point.dart';
export 'routing/route_profile.dart';
export 'routing/route_refused.dart';
export 'routing/route_stretch.dart';
export 'search/place.dart';
export 'search/search_refused.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
export 'sync_tables.dart';
@ -809,6 +815,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _ic7f85bc.RouteStretch) {
return _ic7f85bc.RouteStretch.fromJson(data) as T;
}
if (t == _iadh29yw.Place) {
return _iadh29yw.Place.fromJson(data) as T;
}
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _i5b9b17z.Workout) {
return _i5b9b17z.Workout.fromJson(data) as T;
}
@ -866,6 +878,13 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_ic7f85bc.RouteStretch?>()) {
return (data != null ? _ic7f85bc.RouteStretch.fromJson(data) : null) as T;
}
if (t == _is.getType<_iadh29yw.Place?>()) {
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
}
if (t == _is.getType<_ihbxkwke.SearchRefused?>()) {
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
as T;
}
if (t == _is.getType<_i5b9b17z.Workout?>()) {
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
}
@ -900,6 +919,10 @@ class Protocol extends _is.DatabaseSerializationManager {
.toList()
as T;
}
if (t == List<_i6fosizj.Place>) {
return (data as List).map((e) => deserialize<_i6fosizj.Place>(e)).toList()
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -939,6 +962,8 @@ class Protocol extends _is.DatabaseSerializationManager {
_ifl4j5ep.RouteProfile => 'RouteProfile',
_ix4aepwk.RouteRefused => 'RouteRefused',
_ic7f85bc.RouteStretch => 'RouteStretch',
_iadh29yw.Place => 'Place',
_ihbxkwke.SearchRefused => 'SearchRefused',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
_ => null,
@ -990,6 +1015,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'RouteRefused';
case _ic7f85bc.RouteStretch():
return 'RouteStretch';
case _iadh29yw.Place():
return 'Place';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _i5b9b17z.Workout():
return 'Workout';
case _i92mefto.WorkoutSeries():
@ -1082,6 +1111,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'RouteStretch') {
return deserialize<_ic7f85bc.RouteStretch>(data['data']);
}
if (dataClassName == 'Place') {
return deserialize<_iadh29yw.Place>(data['data']);
}
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'Workout') {
return deserialize<_i5b9b17z.Workout>(data['data']);
}

View file

@ -7,3 +7,6 @@ map:
routing:
- offered:
- plan:
search:
- offered:
- places:

View file

@ -0,0 +1,136 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// A place found by the instance's search (ADR 0030, D50): what the planner
/// lists and makes a waypoint of.
abstract class Place
implements _is.SerializableModel, _is.ProtocolSerialization {
Place._({
required this.name,
this.detail,
required this.kind,
required this.lat,
required this.lon,
});
factory Place({
required String name,
String? detail,
required String kind,
required double lat,
required double lon,
}) = _PlaceImpl;
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
return Place(
name: jsonSerialization['name'] as String,
detail: jsonSerialization['detail'] as String?,
kind: jsonSerialization['kind'] as String,
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
/// Its name, or its street and number for an address.
String name;
/// Where it is, as the list shows under the name: its street, its town and
/// its country, those the name doesn't say already; null where none.
String? detail;
/// What kind of place, as Photon names it (`city`, `street`, `house`,
/// `locality`, `other`, …).
String kind;
double lat;
double lon;
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_is.useResult
Place copyWith({
String? name,
String? detail,
String? kind,
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PlaceImpl extends Place {
_PlaceImpl({
required String name,
String? detail,
required String kind,
required double lat,
required double lon,
}) : super._(
name: name,
detail: detail,
kind: kind,
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
Place copyWith({
String? name,
Object? detail = _Undefined,
String? kind,
double? lat,
double? lon,
}) {
return Place(
name: name ?? this.name,
detail: detail is String? ? detail : this.detail,
kind: kind ?? this.kind,
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,71 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// Why the instance searches nothing (ADR 0030, D50): `off` where it has no
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
/// `unreachable` where Photon didn't answer.
abstract class SearchRefused
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
SearchRefused._({required this.reason});
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return SearchRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SearchRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
String toString() {
return 'SearchRefused(reason: $reason)';
}
}
class _SearchRefusedImpl extends SearchRefused {
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SearchRefused copyWith({String? reason}) {
return SearchRefused(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,118 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_train_server/src/generated/protocol.dart';
/// Finds places for a query (ADR 0030, D50): the instance's Photon, or a
/// fake in tests. Answers Photon's GeoJSON, else throws [PhotonFailed].
abstract interface class PlaceFinder {
Future<Map<String, Object?>> search(
String query, {
RoutePoint? near,
String? language,
required int limit,
});
}
/// Photon didn't answer, or not with GeoJSON.
final class const PhotonFailed(final String reason) implements Exception {
@override
String toString() => 'PhotonFailed: $reason';
}
/// Photon (v1.3.0) at [address], asked over plain HTTP on the network it
/// shares with Train's server alone: `/api` with the query, the point its
/// results are nearest to first, the language of their names.
class HttpPhoton(final Uri address) implements PlaceFinder {
static const _timeout = Duration(seconds: 10);
@override
Future<Map<String, Object?>> search(
String query, {
RoutePoint? near,
String? language,
required int limit,
}) async {
final client = HttpClient()..connectionTimeout = _timeout;
try {
final uri = address
.resolve('api')
.replace(
queryParameters: {
'q': query,
'limit': '$limit',
if (near != null) ...{
'lat': '${near.lat}',
'lon': '${near.lon}',
},
'lang': ?language,
},
);
final HttpClientResponse response;
final String body;
try {
response = await (await client.getUrl(uri)).close().timeout(_timeout);
body = await utf8.decodeStream(response).timeout(_timeout);
} on Object catch (error) {
throw PhotonFailed(error.runtimeType.toString());
}
if (response.statusCode != HttpStatus.ok) {
throw PhotonFailed('HTTP ${response.statusCode}');
}
try {
return jsonDecode(body) as Map<String, Object?>;
} on Object {
throw const PhotonFailed('no GeoJSON');
}
} finally {
client.close(force: true);
}
}
}
/// The places in Photon's [geoJson], in its order: each feature a point
/// with a name, or a street (with its number) for an address; the rest
/// left out.
List<Place> placesOf(Map<String, Object?> geoJson) => [
for (final feature in switch (geoJson['features']) {
final List<Object?> features => features,
_ => const <Object?>[],
})
if (feature case {
'geometry': {
'type': 'Point',
'coordinates': [final num lon, final num lat, ...],
},
'properties': final Map<String, Object?> properties,
})
?_placeOf(properties, lat.toDouble(), lon.toDouble()),
];
Place? _placeOf(Map<String, Object?> properties, double lat, double lon) {
String? text(String key) => switch (properties[key]) {
final String value when value.trim().isNotEmpty => value.trim(),
_ => null,
};
final street = switch ((text('street'), text('housenumber'))) {
(final street?, final number?) => '$street $number',
(final street?, null) => street,
_ => null,
};
final name = text('name') ?? street;
if (name == null) {
return null;
}
final town = text('city') ?? text('district') ?? text('county');
final detail = <String>[
if (street != null && street != name) street,
if (town != null && town != name) town,
if (text('country') case final country? when country != name) country,
];
return Place(
name: name,
detail: detail.isEmpty ? null : detail.join(', '),
kind: text('type') ?? 'other',
lat: lat,
lon: lon,
);
}

View file

@ -0,0 +1,14 @@
### A place found by the instance's search (ADR 0030, D50): what the planner
### lists and makes a waypoint of.
class: Place
fields:
### Its name, or its street and number for an address.
name: String
### Where it is, as the list shows under the name: its street, its town and
### its country, those the name doesn't say already; null where none.
detail: String?
### What kind of place, as Photon names it (`city`, `street`, `house`,
### `locality`, `other`, …).
kind: String
lat: double
lon: double

View file

@ -0,0 +1,109 @@
import 'dart:collection';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/search/photon.dart';
import 'package:serverpod/serverpod.dart';
/// Searching for places on this instance (ADR 0030, D50), through its Photon
/// where it has one, for a person as often as [perMinute] allows. Neither
/// the query nor the places are kept or logged; the counts live in memory
/// alone.
class PlaceSearch {
PlaceSearch({this.finder});
/// The search of [pod], made the first time it is asked for, from the
/// operator's `PHOTON_URL` (empty: none). Tests set their own.
factory PlaceSearch.of(Serverpod pod) => _ofPod[pod] ??= PlaceSearch(
finder: switch (Platform.environment['PHOTON_URL']?.trim()) {
final url? when url.isNotEmpty => HttpPhoton(Uri.parse(url)),
_ => null,
},
);
static final _ofPod = Expando<PlaceSearch>('PlaceSearch');
/// Makes [search] the one of [pod].
static void use(Serverpod pod, PlaceSearch search) => _ofPod[pod] = search;
/// The instance's Photon; none: it finds nothing.
final PlaceFinder? finder;
/// The fewest and the most characters a query has.
static const minQuery = 2;
static const maxQuery = 200;
/// The most places an answer lists.
static const limit = 8;
/// The most searches a person may make in a minute: the planner asks as
/// they type, a pause after each word.
static const perMinute = 60;
/// The languages Photon names places in; any other gets its own names.
static const languages = {'en', 'de'};
final _asked = HashMap<UuidValue, Queue<DateTime>>();
/// The places [query] finds for [person], those nearest [near] first, named
/// in [language]. Refused ([SearchRefused]) without a Photon, for a query
/// or a point it doesn't take, beyond [perMinute], where Photon doesn't
/// answer.
Future<List<Place>> places(
UuidValue person,
String query, {
RoutePoint? near,
String? language,
}) async {
final finder = this.finder;
if (finder == null) {
throw SearchRefused(reason: 'off');
}
final trimmed = query.trim();
if (trimmed.length < minQuery ||
trimmed.length > maxQuery ||
(near != null && !_onEarth(near))) {
throw SearchRefused(reason: 'invalid');
}
_count(person);
try {
return placesOf(
await finder.search(
trimmed,
near: near,
language: languages.contains(language) ? language : null,
limit: limit,
),
);
} on PhotonFailed {
throw SearchRefused(reason: 'unreachable');
}
}
static bool _onEarth(RoutePoint point) =>
point.lat.isFinite &&
point.lon.isFinite &&
point.lat.abs() <= 90 &&
point.lon.abs() <= 180;
/// Counts a search of [person]'s, refusing it beyond [perMinute] in the
/// last minute.
void _count(UuidValue person) {
final now = clock.now();
final asked = _asked.putIfAbsent(person, Queue.new);
while (asked.isNotEmpty &&
now.difference(asked.first) >= const Duration(minutes: 1)) {
asked.removeFirst();
}
if (asked.length >= perMinute) {
throw SearchRefused(reason: 'tooOften');
}
asked.addLast(now);
// Those who searched nothing for a minute are forgotten.
_asked.removeWhere(
(_, times) =>
times.isEmpty ||
now.difference(times.last) >= const Duration(minutes: 1),
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/search/place_search.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Places found by the instance's Photon (ADR 0030, D50), for those signed
/// in: Photon has no sign-in of its own, so the server alone reaches it.
/// Neither the query nor the places are kept or logged. Refusals are
/// `SearchRefused`.
class SearchEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Whether the instance searches for places.
Future<bool> offered(Session session) async =>
PlaceSearch.of(session.serverpod).finder != null;
/// The places [query] finds, nearest [near] first (the map's centre),
/// named in [language] (`en`, `de`; else each its own name). Refused where
/// the instance has no Photon or it fails, for a query of fewer than
/// `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery` characters,
/// and beyond `PlaceSearch.perMinute` searches a minute.
Future<List<Place>> places(
Session session,
String query, {
RoutePoint? near,
String? language,
}) => PlaceSearch.of(session.serverpod).places(
session.authenticated!.authUserId,
query,
near: near,
language: language,
);
}

View file

@ -0,0 +1,8 @@
### Why the instance searches nothing (ADR 0030, D50): `off` where it has no
### Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
### longer than `PlaceSearch.maxQuery` characters or a place off the earth,
### `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
### `unreachable` where Photon didn't answer.
exception: SearchRefused
fields:
reason: String