feat(places): backend for the photo map (geo clusters API)
Phase 1 server side, gated on OXICLOUD_ENABLE_PLACES (off by default): - migration: partial index on storage.file_metadata(longitude, latitude). - FileBlobReadRepository::list_geo_clusters — plain-SQL grid aggregation (no PostGIS) scoped to the caller's own non-trashed photos, returning a centroid, count and a representative file id per non-empty cell. - PlacesService (caller_id-scoped; user-scoped data needs no authz check, mirroring RecentService) with a zoom→cell-size mapping. - GET /api/photos/geo?bbox=w,s,e,n&zoom=N returning GeoCluster[]. The route is mounted only when the Places service is present, and is registered in the OpenAPI path list. The map frontend (PMTiles serving + MapLibre module) is deferred pending the basemap-sourcing and vendoring decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -32,6 +32,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -416,6 +417,56 @@ impl FileBlobReadRepository {
|
||||
|
||||
Ok((files, sort_dates, dims))
|
||||
}
|
||||
|
||||
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
|
||||
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
|
||||
/// Returns one cluster per non-empty cell with its centroid, photo count
|
||||
/// and a representative photo id (for the cluster thumbnail).
|
||||
pub async fn list_geo_clusters(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
bounds: GeoBounds,
|
||||
cell: f64,
|
||||
) -> Result<Vec<GeoCluster>, DomainError> {
|
||||
let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT count(*) AS n,
|
||||
avg(fm.longitude) AS clng,
|
||||
avg(fm.latitude) AS clat,
|
||||
min(fm.file_id::text) AS sample_id
|
||||
FROM storage.file_metadata fm
|
||||
JOIN storage.files fi ON fi.id = fm.file_id
|
||||
WHERE fi.user_id = $1
|
||||
AND NOT fi.is_trashed
|
||||
AND fm.latitude IS NOT NULL
|
||||
AND fm.longitude IS NOT NULL
|
||||
AND fm.longitude BETWEEN $2 AND $3
|
||||
AND fm.latitude BETWEEN $4 AND $5
|
||||
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(bounds.west)
|
||||
.bind(bounds.east)
|
||||
.bind(bounds.south)
|
||||
.bind(bounds.north)
|
||||
.bind(cell)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(n, clng, clat, sample_id)| GeoCluster {
|
||||
lng: clng,
|
||||
lat: clat,
|
||||
count: n,
|
||||
sample_file_id: sample_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
Reference in New Issue
Block a user