feat(todo): the server finds places through the instance's Photon (ADR 0033, D32)

Todo's locations search through the person's own instance, as Train's
planner does, not a public service: `SearchEndpoint` on `PlaceSearch`, for
those signed in, 60 searches a minute, nothing kept or logged. Its `Place`
carries the address's parts and the object in OpenStreetMap, and a shop's
car park or charger is left out, which came first for `Lindau EDEKA`.

`compose.todo-search.yaml` puts Todo's server on compose.search.yaml's
Photon. A file of its own: without it Todo starts as it did, and offers no
search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 14:58:38 +01:00
commit c9d3eb4dd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 1880 additions and 12 deletions

View file

@ -14,6 +14,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_todo_server/src/generated/future_calls.dart'
as _i772x17y;
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
as _iywv9say;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -21,11 +23,67 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import '../search/search_endpoint.dart' as _i2f0v2ey;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'search': _i2f0v2ey.SearchEndpoint()
..initialize(
server,
'search',
null,
),
};
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<_iywv9say.PlacePoint?>(),
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

@ -13,6 +13,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
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_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
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'
@ -25,12 +27,18 @@ import 'label/label.dart' as _ivak08z4;
import 'privacy/content_key.dart' as _imed3x5d;
import 'project/project.dart' as _i9c44spp;
import 'project/section.dart' as _imadkvi9;
import 'search/place.dart' as _iadh29yw;
import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
export 'label/label.dart';
export 'privacy/content_key.dart';
export 'project/project.dart';
export 'project/section.dart';
export 'search/place.dart';
export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'sync_tables.dart';
@ -486,6 +494,15 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _imadkvi9.Section) {
return _imadkvi9.Section.fromJson(data) as T;
}
if (t == _iadh29yw.Place) {
return _iadh29yw.Place.fromJson(data) as T;
}
if (t == _itf0ur2u.PlacePoint) {
return _itf0ur2u.PlacePoint.fromJson(data) as T;
}
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _icak60vd.TodoSettings) {
return _icak60vd.TodoSettings.fromJson(data) as T;
}
@ -504,12 +521,26 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_imadkvi9.Section?>()) {
return (data != null ? _imadkvi9.Section.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<_itf0ur2u.PlacePoint?>()) {
return (data != null ? _itf0ur2u.PlacePoint.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<_icak60vd.TodoSettings?>()) {
return (data != null ? _icak60vd.TodoSettings.fromJson(data) : null) as T;
}
if (t == _is.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == List<_ih6de0l6.Place>) {
return (data as List).map((e) => deserialize<_ih6de0l6.Place>(e)).toList()
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -537,6 +568,9 @@ class Protocol extends _is.DatabaseSerializationManager {
_imed3x5d.ContentKey => 'ContentKey',
_i9c44spp.Project => 'Project',
_imadkvi9.Section => 'Section',
_iadh29yw.Place => 'Place',
_itf0ur2u.PlacePoint => 'PlacePoint',
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_ => null,
@ -564,6 +598,12 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'Project';
case _imadkvi9.Section():
return 'Section';
case _iadh29yw.Place():
return 'Place';
case _itf0ur2u.PlacePoint():
return 'PlacePoint';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _icak60vd.TodoSettings():
return 'TodoSettings';
case _ib4f8uyw.Task():
@ -620,6 +660,15 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'Section') {
return deserialize<_imadkvi9.Section>(data['data']);
}
if (dataClassName == 'Place') {
return deserialize<_iadh29yw.Place>(data['data']);
}
if (dataClassName == 'PlacePoint') {
return deserialize<_itf0ur2u.PlacePoint>(data['data']);
}
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'TodoSettings') {
return deserialize<_icak60vd.TodoSettings>(data['data']);
}

View file

@ -0,0 +1,3 @@
search:
- offered:
- places:

View file

@ -0,0 +1,226 @@
/* 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 0033, D32): what the location
/// sheet lists and a task's location is made of. Never stored by the server:
/// a task's location is in its sealed payload.
abstract class Place
implements _is.SerializableModel, _is.ProtocolSerialization {
Place._({
required this.name,
this.detail,
required this.kind,
this.osmValue,
this.osmType,
this.osmId,
this.street,
this.housenumber,
this.district,
this.city,
required this.lat,
required this.lon,
});
factory Place({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
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,
osmValue: jsonSerialization['osmValue'] as String?,
osmType: jsonSerialization['osmType'] as String?,
osmId: jsonSerialization['osmId'] as int?,
street: jsonSerialization['street'] as String?,
housenumber: jsonSerialization['housenumber'] as String?,
district: jsonSerialization['district'] as String?,
city: jsonSerialization['city'] 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;
/// What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
/// …); null where Photon doesn't say.
String? osmValue;
/// Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
/// its ID; what its opening hours are looked up and its page opened by.
String? osmType;
int? osmId;
/// Its address, each part where Photon has it.
String? street;
String? housenumber;
String? district;
String? city;
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,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'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,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) : super._(
name: name,
detail: detail,
kind: kind,
osmValue: osmValue,
osmType: osmType,
osmId: osmId,
street: street,
housenumber: housenumber,
district: district,
city: city,
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,
Object? osmValue = _Undefined,
Object? osmType = _Undefined,
Object? osmId = _Undefined,
Object? street = _Undefined,
Object? housenumber = _Undefined,
Object? district = _Undefined,
Object? city = _Undefined,
double? lat,
double? lon,
}) {
return Place(
name: name ?? this.name,
detail: detail is String? ? detail : this.detail,
kind: kind ?? this.kind,
osmValue: osmValue is String? ? osmValue : this.osmValue,
osmType: osmType is String? ? osmType : this.osmType,
osmId: osmId is int? ? osmId : this.osmId,
street: street is String? ? street : this.street,
housenumber: housenumber is String? ? housenumber : this.housenumber,
district: district is String? ? district : this.district,
city: city is String? ? city : this.city,
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,93 @@
/* 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 point on the earth, in degrees (WGS 84): what a search's places are
/// nearest to first (ADR 0033, D32).
abstract class PlacePoint
implements _is.SerializableModel, _is.ProtocolSerialization {
PlacePoint._({
required this.lat,
required this.lon,
});
factory PlacePoint({
required double lat,
required double lon,
}) = _PlacePointImpl;
factory PlacePoint.fromJson(Map<String, dynamic> jsonSerialization) {
return PlacePoint(
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
double lat;
double lon;
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PlacePoint copyWith({
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _PlacePointImpl extends PlacePoint {
_PlacePointImpl({
required double lat,
required double lon,
}) : super._(
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PlacePoint copyWith({
double? lat,
double? lon,
}) {
return PlacePoint(
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 0033, D32): `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,138 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
/// Finds places for a query (ADR 0033, D32): 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, {
PlacePoint? 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 Todo'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, {
PlacePoint? 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);
}
}
}
/// What OpenStreetMap calls what is no place to go, however a shop's car park
/// or charger is named: searching for `EDEKA` should find the shop.
const _noDestination = {
'charging_station',
'parking',
'parking_entrance',
'parking_space',
};
/// 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; what is no
/// place to go and 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;
final osmValue = text('osm_value');
if (name == null || _noDestination.contains(osmValue)) {
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',
osmValue: osmValue,
osmType: text('osm_type'),
osmId: switch (properties['osm_id']) {
final num id => id.toInt(),
_ => null,
},
street: text('street'),
housenumber: text('housenumber'),
district: text('district'),
city: text('city'),
lat: lat,
lon: lon,
);
}

View file

@ -0,0 +1,27 @@
### A place found by the instance's search (ADR 0033, D32): what the location
### sheet lists and a task's location is made of. Never stored by the server:
### a task's location is in its sealed payload.
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
### What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
### …); null where Photon doesn't say.
osmValue: String?
### Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
### its ID; what its opening hours are looked up and its page opened by.
osmType: String?
osmId: int?
### Its address, each part where Photon has it.
street: String?
housenumber: String?
district: String?
city: String?
lat: double
lon: double

View file

@ -0,0 +1,6 @@
### A point on the earth, in degrees (WGS 84): what a search's places are
### nearest to first (ADR 0033, D32).
class: PlacePoint
fields:
lat: double
lon: double

View file

@ -0,0 +1,112 @@
import 'dart:collection';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/photon.dart';
import 'package:serverpod/serverpod.dart';
/// Searching for places on this instance (ADR 0033, D32), 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 asked of Photon, as what is no place to go is left out of it.
static const _askedOfPhoton = 12;
/// The most searches a person may make in a minute: the location sheet 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, {
PlacePoint? 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: _askedOfPhoton,
),
).take(limit).toList();
} on PhotonFailed {
throw SearchRefused(reason: 'unreachable');
}
}
static bool _onEarth(PlacePoint 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_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_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 0033, D32), 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, and a task's location
/// stays in its sealed payload. 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, or
/// the person), 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, {
PlacePoint? 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 0033, D32): `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

View file

@ -0,0 +1,190 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/photon.dart';
import 'package:halkyon_todo_server/src/search/place_search.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../../unit/search/photon_test.dart' show photonAnswer;
import '../test_tools/serverpod_test_tools.dart';
/// Photon as the tests want it: what it answers, and what it was asked.
class _FakePhoton implements PlaceFinder {
final asked = <({String query, PlacePoint? near, String? language})>[];
final limits = <int>[];
bool failing = false;
/// What Photon finds.
List<Map<String, Object?>> answer = const [
{
'lat': 52.47,
'lon': 13.4,
'name': 'Tempelhofer Feld',
'type': 'other',
'city': 'Berlin',
'country': 'Germany',
},
];
@override
Future<Map<String, Object?>> search(
String query, {
PlacePoint? near,
String? language,
required int limit,
}) async {
asked.add((query: query, near: near, language: language));
limits.add(limit);
if (failing) {
throw const PhotonFailed('down');
}
return photonAnswer(answer);
}
}
Matcher _refused(String reason) =>
throwsA(isA<SearchRefused>().having((r) => r.reason, 'reason', reason));
/// Places found by the instance's Photon (ADR 0033, D32): for the people
/// signed in, where the instance has one, within a limit a minute.
void main() {
withServerpod('Searching for places', (sessionBuilder, endpoints) {
late _FakePhoton photon;
final alice = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final bob = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
void useSearch(PlaceSearch search) =>
PlaceSearch.use(sessionBuilder.build().serverpod, search);
setUp(() {
photon = _FakePhoton();
useSearch(PlaceSearch(finder: photon));
});
test('finds places for someone signed in, nearest the map first, in '
'their language', () async {
expect(await endpoints.search.offered(alice), isTrue);
final places = await endpoints.search.places(
alice,
' Tempelhof ',
near: PlacePoint(lat: 52.5, lon: 13.4),
language: 'de',
);
expect(places.single.name, 'Tempelhofer Feld');
expect(places.single.detail, 'Berlin, Germany');
final asked = photon.asked.single;
expect(asked.query, 'Tempelhof');
expect(asked.near?.lat, 52.5);
expect(asked.language, 'de');
});
test('a language Photon has no names in asks for none', () async {
await endpoints.search.places(alice, 'Paris', language: 'fr');
expect(photon.asked.single.language, isNull);
});
test('searches nothing for nobody signed in', () async {
final nobody = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
await expectLater(
endpoints.search.places(nobody, 'Berlin'),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.search.offered(nobody),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(photon.asked, isEmpty);
});
test('refuses every search where the instance has no Photon', () async {
useSearch(PlaceSearch());
expect(await endpoints.search.offered(alice), isFalse);
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('off'),
);
});
test('refuses a query too short or too long, or a point off the earth, '
'asking Photon nothing', () async {
for (final query in ['', ' x ', 'x' * (PlaceSearch.maxQuery + 1)]) {
await expectLater(
endpoints.search.places(alice, query),
_refused('invalid'),
reason: query,
);
}
await expectLater(
endpoints.search.places(
alice,
'Berlin',
near: PlacePoint(lat: 91, lon: 0),
),
_refused('invalid'),
);
expect(photon.asked, isEmpty);
});
test('refuses a person beyond the limit a minute, not others, and '
'takes them again a minute later', () async {
final start = DateTime.utc(2026, 10, 29, 12);
await withClock(Clock.fixed(start), () async {
for (var i = 0; i < PlaceSearch.perMinute; i++) {
await endpoints.search.places(alice, 'Berlin');
}
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('tooOften'),
);
await endpoints.search.places(bob, 'Berlin');
});
await withClock(
Clock.fixed(start.add(const Duration(minutes: 1))),
() => endpoints.search.places(alice, 'Berlin'),
);
});
test('asks Photon for more than it lists, as what is no place to go is '
'left out, and lists no more than the limit', () async {
photon.answer = [
for (var i = 0; i < 4; i++)
{
'lat': 1,
'lon': 2,
'name': 'EDEKA',
'type': 'other',
'osm_value': 'parking',
},
for (var i = 0; i < PlaceSearch.limit + 2; i++)
{'lat': 1, 'lon': 2, 'name': 'EDEKA $i', 'type': 'house'},
];
final places = await endpoints.search.places(alice, 'EDEKA');
expect(photon.limits.single, greaterThan(PlaceSearch.limit));
expect(places, hasLength(PlaceSearch.limit));
expect(places.first.name, 'EDEKA 0');
});
test("says Photon didn't answer where it fails", () async {
photon.failing = true;
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('unreachable'),
);
});
});
}

View file

@ -11,9 +11,14 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'package:halkyon_todo_server/src/generated/future_calls.dart'
as _i772x17y;
import 'package:halkyon_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
as _iywv9say;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_todo_server/src/generated/protocol.dart';
@ -150,6 +155,8 @@ void withServerpod(
class TestEndpoints {
late final futureCalls = _FutureCalls();
late final _SearchEndpoint search;
}
class _InternalTestEndpoints extends TestEndpoints
@ -158,13 +165,94 @@ class _InternalTestEndpoints extends TestEndpoints
void initialize(
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {}
) {
search = _SearchEndpoint(
endpoints,
serializationManager,
);
}
}
class _FutureCalls {
late final pruning = _PruningFutureCall();
}
class _SearchEndpoint {
_SearchEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<bool> offered(_ist.TestSessionBuilder sessionBuilder) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'search',
method: 'offered',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'search',
methodName: 'offered',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<_ih6de0l6.Place>> places(
_ist.TestSessionBuilder sessionBuilder,
String query, {
_iywv9say.PlacePoint? near,
String? language,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'search',
method: 'places',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'search',
methodName: 'places',
parameters: _ist.testObjectToJson({
'query': query,
'near': near,
'language': language,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_ih6de0l6.Place>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =

View file

@ -0,0 +1,246 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/photon.dart';
import 'package:test/test.dart';
/// Photon's answer as its API documents it (`docs/api-v1.md` of v1.3.0,
/// read 29 Oct 2026): GeoJSON features, points `[lon, lat]`, their
/// properties GeocodeJSON's (`name`, `street`, `housenumber`, `city`,
/// `district`, `country`, `type`, …).
Map<String, Object?> photonAnswer(List<Map<String, Object?>> places) => {
'type': 'FeatureCollection',
'features': [
for (final place in places)
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [place['lon'], place['lat']],
},
'properties': {
for (final MapEntry(:key, :value) in place.entries)
if (key != 'lat' && key != 'lon') key: value,
},
},
],
};
void main() {
test('a place is its name, where it is and its kind', () {
final places = placesOf(
photonAnswer([
{
'lat': 52.5145,
'lon': 13.3501,
'name': 'Tiergarten',
'osm_key': 'leisure',
'osm_value': 'park',
'type': 'other',
'district': 'Tiergarten',
'city': 'Berlin',
'country': 'Germany',
},
{
'lat': 52.52,
'lon': 13.405,
'name': 'Berlin',
'type': 'city',
'city': 'Berlin',
'country': 'Germany',
},
]),
);
// Serverpod's models have no equality of their own.
expect(
[for (final place in places) place.toJson()],
[
Place(
name: 'Tiergarten',
detail: 'Berlin, Germany',
kind: 'other',
osmValue: 'park',
district: 'Tiergarten',
city: 'Berlin',
lat: 52.5145,
lon: 13.3501,
).toJson(),
Place(
name: 'Berlin',
detail: 'Germany',
kind: 'city',
city: 'Berlin',
lat: 52.52,
lon: 13.405,
).toJson(),
],
);
});
test('an address without a name is its street and number', () {
final [place] = placesOf(
photonAnswer([
{
'lat': 48.137,
'lon': 11.575,
'type': 'house',
'street': 'Marienplatz',
'housenumber': '8',
'city': 'München',
'country': 'Deutschland',
},
]),
);
expect(place.name, 'Marienplatz 8');
expect(place.detail, 'München, Deutschland');
});
test('features without a point or a name, and answers without features, '
'are no places', () {
expect(
placesOf({
'features': [
{
'geometry': {
'type': 'LineString',
'coordinates': [
[1, 2],
[3, 4],
],
},
'properties': {'name': 'A line'},
},
{
'geometry': {
'type': 'Point',
'coordinates': [1, 2],
},
'properties': {'type': 'house'},
},
'not a feature',
],
}),
isEmpty,
);
expect(placesOf(const {}), isEmpty);
});
test('a place keeps its address and its object in OpenStreetMap, which '
"tell two of a town's supermarkets apart", () {
final [place] = placesOf(
photonAnswer([
{
'lat': 47.5606,
'lon': 9.7011,
'name': 'EDEKA',
'type': 'house',
'osm_type': 'W',
'osm_id': 79817244,
'osm_key': 'shop',
'osm_value': 'supermarket',
'street': 'Ludwig-Kick-Straße',
'housenumber': '1',
'district': 'Aeschach',
'city': 'Lindau (Bodensee)',
'country': 'Deutschland',
},
]),
);
expect(place.osmValue, 'supermarket');
expect(place.osmType, 'W');
expect(place.osmId, 79817244);
expect(place.street, 'Ludwig-Kick-Straße');
expect(place.housenumber, '1');
expect(place.district, 'Aeschach');
expect(place.city, 'Lindau (Bodensee)');
expect(
place.detail,
'Ludwig-Kick-Straße 1, Lindau (Bodensee), Deutschland',
);
});
test('a place without what OpenStreetMap knows of it has none of it', () {
final [place] = placesOf(
photonAnswer([
{'lat': 1, 'lon': 2, 'name': 'Somewhere', 'type': 'other'},
]),
);
expect(place.osmValue, isNull);
expect(place.osmType, isNull);
expect(place.osmId, isNull);
expect(place.street, isNull);
expect(place.district, isNull);
});
test('a car park or a charger named after a shop is no place to go: a '
'search for EDEKA finds the shop', () {
final places = placesOf(
photonAnswer([
for (final value in [
'parking',
'parking_entrance',
'parking_space',
'charging_station',
])
{
'lat': 47.56,
'lon': 9.70,
'name': 'EDEKA',
'type': 'other',
'osm_value': value,
},
{
'lat': 47.5606,
'lon': 9.7011,
'name': 'EDEKA',
'type': 'house',
'osm_value': 'supermarket',
},
]),
);
expect([for (final place in places) place.osmValue], ['supermarket']);
});
test('HttpPhoton asks /api with the query, the point, the language and '
'the limit, and fails on what is no answer', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final asked = <Uri>[];
var status = HttpStatus.ok;
server.listen((request) async {
asked.add(request.uri);
request.response
..statusCode = status
..write(jsonEncode(photonAnswer(const [])));
await request.response.close();
});
addTearDown(server.close);
final photon = HttpPhoton(Uri.parse('http://127.0.0.1:${server.port}/'));
await photon.search(
'Tempelhofer Feld',
near: PlacePoint(lat: 52.47, lon: 13.4),
language: 'de',
limit: 8,
);
expect(asked.single.path, '/api');
expect(asked.single.queryParameters, {
'q': 'Tempelhofer Feld',
'limit': '8',
'lat': '52.47',
'lon': '13.4',
'lang': 'de',
});
status = HttpStatus.badRequest;
await expectLater(
photon.search('x', limit: 8),
throwsA(isA<PhotonFailed>()),
);
});
}