feat: geofenced & associated news and events rollup (#406)#424
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking a boundary (e.g. Brecksville Reservation) or an organization
(e.g. Cleveland Metroparks) now shows News & Events for everything the
boundary contains or the org owns, not just content collected against
that single POI.
- backend: getRollupPoiIds() expands a POI id into the set whose
news/events roll up to it — contained POIs (ST_Contains) for a
boundary, owned/associated POIs plus POIs inside owned park
boundaries for an organization, just [id] for a plain point.
Spatial steps degrade gracefully to the non-spatial set on PostGIS
failure, mirroring getContainingBoundaries.
- backend: /api/pois/:id/{news,events,tab-counts} query poi_id = ANY()
over the rollup set and return each item's source poi_id + poi_name.
- frontend: PoiNews/PoiEvents label rolled-up items with their source
POI and link them to that POI's own permalink.
Closes #406.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements geofenced and associated news and events rollup functionality for boundary and organization POIs. It introduces a helper function getRollupPoiIds to expand target POI IDs and updates endpoints and frontend components to support this rollup. The feedback identifies two key issues: first, a PostGIS query issue in geoService.js where ST_GeometryN will return NULL for simple LINESTRING geometries unless wrapped in ST_Multi; second, a lack of input validation for the id parameter in the /api/pois/:id/news and /api/pois/:id/events endpoints.
| WHEN p.poi_roles && ARRAY['trail','boundary','river']::text[] AND p.geometry IS NOT NULL THEN | ||
| ST_StartPoint(ST_GeometryN(ST_GeomFromGeoJSON(p.geometry::text), 1)) |
There was a problem hiding this comment.
The use of ST_GeometryN directly on the parsed GeoJSON geometry will return NULL if the geometry is a simple LINESTRING (which is common for custom or imported trails/rivers), as ST_GeometryN is only defined for multi-geometries or collections in PostGIS. Consequently, ST_StartPoint will receive NULL and return NULL, causing simple linear features to be silently excluded from the spatial rollup.
Wrapping the geometry in ST_Multi first guarantees it is treated as a multi-geometry, making it compatible with ST_GeometryN regardless of whether the underlying geometry is a simple or multi-line string.
| WHEN p.poi_roles && ARRAY['trail','boundary','river']::text[] AND p.geometry IS NOT NULL THEN | |
| ST_StartPoint(ST_GeometryN(ST_GeomFromGeoJSON(p.geometry::text), 1)) | |
| WHEN p.poi_roles && ARRAY['trail','boundary','river']::text[] AND p.geometry IS NOT NULL THEN | |
| ST_StartPoint(ST_GeometryN(ST_Multi(ST_GeomFromGeoJSON(p.geometry::text)), 1)) |
| try { | ||
| const { id } = req.params; | ||
| const limit = parseInt(req.query.limit) || 50; | ||
| // Roll up boundary/org POIs to include contained/owned POIs (#406) | ||
| const poiIds = await getRollupPoiIds(pool, id); |
There was a problem hiding this comment.
The id parameter from req.params is not validated before being passed to getRollupPoiIds. If a non-integer or invalid ID is provided, it will propagate to the database query and could result in unnecessary database roundtrips or errors.
Adding an upfront validation check (similar to the one implemented in /api/pois/:id/tab-counts) ensures consistency and improves defensive programming.
Note: The same validation should also be applied to the /api/pois/:id/events endpoint.
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid POI id' });
}
const limit = parseInt(req.query.limit) || 50;
// Roll up boundary/org POIs to include contained/owned POIs (#406)
const poiIds = await getRollupPoiIds(pool, id);…review) - Replace the all-POIs candidate CTE with two paths: point POIs join directly on the indexed geom column (uses idx_pois_geom GiST index), linear features parse GeoJSON only for the small trail/river/boundary subset. CVNP containment now ~2ms via index scan. - getRollupPoiIds returns [] for a non-existent POI, consistent with the invalid-input path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iew) ST_StartPoint returns NULL for polygon-type geometries, so boundary-role POIs contained within a larger boundary were silently dropped from the rollup. ST_PointOnSurface returns a guaranteed-interior representative point for LineString, MultiLineString, Polygon, and MultiPolygon alike — verified against all four geometry types present in the data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pois.geometry is a jsonb column; ST_GeomFromGeoJSON accepts jsonb directly on PostGIS 3.5, so the ::text cast forced a needless jsonb->text round-trip. Drop the cast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gatehouse review dispositionFixed (4 commits):
Justified (no change):
|
Summary
Clicking a geographic boundary or an organization in the sidebar now rolls up News & Events for everything that boundary contains or that org owns — not just content collected against the single POI.
ST_Contains).owner_id) / associated (poi_associations) POIs + POIs geographically inside any owned park boundary.[id], no spatial cost).How
backend/services/geoService.js: newgetRollupPoiIds(pool, poiId). Spatial steps wrapped in try/catch → degrade to the non-spatial expansion on PostGIS failure, mirroringgetContainingBoundaries.backend/server.js:/api/pois/:id/{news,events,tab-counts}querypoi_id = ANY($ids)over the rollup set and return each item'spoi_id+ sourcepoi_name.frontend/src/components/sidebar/PoiNews.jsx,PoiEvents.jsx: label rolled-up items with their source POI (📍) and build the permalink slug from the item's source POI so click-through opens the right detail page.No schema changes, no migrations, no new endpoints. Spec/plan in
.specify/specs/026-geofenced-news/.Closes #406
Test plan
./run.sh buildpasses--full .clean🤖 Generated with Claude Code