diff --git a/.specify/specs/026-geofenced-news/plan.md b/.specify/specs/026-geofenced-news/plan.md new file mode 100644 index 00000000..7a08952c --- /dev/null +++ b/.specify/specs/026-geofenced-news/plan.md @@ -0,0 +1,170 @@ +# Implementation Plan: GeoFenced and Associated News & Events + +> **Spec ID:** 026-geofenced-news +> **Status:** Planning +> **Last Updated:** 2026-05-27 +> **Estimated Effort:** M + +## Summary + +Add a backend helper that expands a target POI id into the set of POI ids whose +news/events should be shown — `[id]` for a normal point, the contained POIs for a +boundary, and the owned/associated POIs (plus POIs inside owned park boundaries) +for an organization. Three existing read endpoints query `poi_id = ANY(ids)` and +return each item's source POI name; the two sidebar tab components label rolled-up +items and link them to their own permalink. + +--- + +## Architecture + +### Data Flow + +1. User selects a boundary or org in the map → Sidebar fetches + `/api/pois/:id/tab-counts`, then `PoiNews`/`PoiEvents` fetch + `/api/pois/:id/news` and `/api/pois/:id/events` (already keyed by `displayItem.id`). +2. Each endpoint calls `getRollupPoiIds(pool, id)` → array of POI ids. +3. The news/events/count SQL filters on `poi_id = ANY($ids)` and joins `pois` + for the source name. +4. Frontend renders items; a source-POI label appears when the item's POI ≠ the + page POI, and clicks build the permalink from the item's source POI slug. + +### POI-id expansion (`getRollupPoiIds`) + +``` +ids = { targetId } +roles = target.poi_roles + +if 'organization' in roles: + owned ∪= SELECT id FROM pois WHERE owner_id = targetId AND not deleted + owned ∪= SELECT physical_poi_id FROM poi_associations WHERE virtual_poi_id = targetId + ids ∪= owned + ids ∪= POIs whose point ⊂ boundary_geom of any owned boundary (spatial) + +if 'boundary' in roles AND target has boundary_geom: + ids ∪= POIs whose point ⊂ target.boundary_geom (spatial) + +return distinct ids +``` + +Spatial steps are wrapped so a PostGIS failure logs a warning and returns the +non-spatial ids (NFR-026-1). Point geometry per POI reuses the `CASE` expression +from `getContainingBoundaries` (point `geom`, else start point of `geometry`). + +--- + +## Technology Choices + +| Component | Technology | Rationale | +|-----------|------------|-----------| +| Containment | PostGIS `ST_Contains` | Same pattern already in `geoService.js` | +| Expansion location | `backend/services/geoService.js` | Co-located with `getContainingBoundaries`; shared by all three endpoints | + +--- + +## Implementation Steps + +### Phase 1: Backend rollup helper + +- [ ] Add `getRollupPoiIds(pool, poiId)` to `backend/services/geoService.js` + returning a de-duplicated array of POI ids (always includes `poiId`). +- [ ] Non-spatial expansion (org owned + associated) runs first; spatial + containment wrapped in try/catch with `[Geo]` warning + fallback. + +### Phase 2: Wire endpoints + +- [ ] `/api/pois/:id/news`: use `getRollupPoiIds`, `WHERE n.poi_id = ANY($1)`, + `JOIN pois src ON src.id = n.poi_id`, return `n.poi_id`, `src.name AS poi_name`. +- [ ] `/api/pois/:id/events`: same expansion + `src.name AS poi_name`, `e.poi_id`. +- [ ] `/api/pois/:id/tab-counts`: count over `poi_id = ANY($ids)`. + +### Phase 3: Frontend labeling + permalink + +- [ ] `PoiNews.jsx`: show source-POI label when `Number(item.poi_id) !== Number(poiId)`; + build slug from `item.poi_name || poiName`. +- [ ] `PoiEvents.jsx`: same. +- [ ] Minimal CSS for the source-POI label. + +--- + +## File Changes + +### Modified Files + +| File | Changes | +|------|---------| +| `backend/services/geoService.js` | Add `getRollupPoiIds` helper | +| `backend/server.js` | Rollup in `/news`, `/events`, `/tab-counts` handlers; return source POI name | +| `frontend/src/components/sidebar/PoiNews.jsx` | Source-POI label + permalink-by-source-slug | +| `frontend/src/components/sidebar/PoiEvents.jsx` | Source-POI label + permalink-by-source-slug | +| `frontend/src/App.css` (or component CSS) | `.poi-item-source` label style | + +No new files, no migrations. + +--- + +## API Implementation + +### `GET /api/pois/:id/news` (boundary/org example) + +**Response (rolled-up item gains `poi_id` + `poi_name`):** +```json +[ + { + "id": 123, + "title": "Trail reopens after storm", + "summary": "...", + "source_url": "https://...", + "publication_date": "2026-05-20", + "poi_id": 5536, + "poi_name": "Old Portage Trail", + "additional_urls": [] + } +] +``` + +--- + +## Testing Strategy + +### Manual Testing + +1. Click Brecksville Reservation (boundary) → News/Events tabs list items from + POIs inside the boundary, each labeled with its POI name. +2. Click Cleveland Metroparks (org) → tabs list items from owned parks and POIs + inside them. +3. Click a rolled-up item → opens that item's own permalink/detail. +4. Click a regular point POI → behaves exactly as before (own content only). +5. Boundary/org with zero rolled-up content → tabs hidden as before. + +### Build / Quality Gates + +- [ ] `./run.sh build` passes +- [ ] Human verification in browser (Phase 5) +- [ ] `gourmand --full .` clean +- [ ] Gatehouse review clean + +--- + +## Rollback Plan + +1. Revert the PR. No migrations or schema changes, so rollback is code-only. + +--- + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Large boundary (county/state) rolls up many items | Low | Existing `LIMIT` caps results; ordered newest-first | +| PostGIS query failure | Med | try/catch fallback to non-spatial expansion (NFR-026-1) | +| Rolled-up item permalink fails to resolve | Med | Navigate using item's source POI slug; App.jsx already resolves destinations/virtual/linear POIs (#412) | +| Spatial cost on every POI click | Low | Helper short-circuits to `[id]` for point POIs — no spatial query | + +--- + +## Changelog + +| Date | Changes | +|------|---------| +| 2026-05-27 | Initial plan | diff --git a/.specify/specs/026-geofenced-news/spec.md b/.specify/specs/026-geofenced-news/spec.md new file mode 100644 index 00000000..59f2d2f9 --- /dev/null +++ b/.specify/specs/026-geofenced-news/spec.md @@ -0,0 +1,136 @@ +# Specification: GeoFenced and Associated News & Events + +> **Spec ID:** 026-geofenced-news +> **Status:** Draft +> **Version:** 0.1.0 +> **Author:** Scott McCarty +> **Date:** 2026-05-27 + +## Overview + +When a user clicks a geographic boundary (e.g. Sand Run) or an organization (e.g. +Summit County Metro Parks), the News and Events sub-tabs should show content for +everything that boundary contains or that organization owns — not just content +collected against that single POI. This rolls up the news/events that already +exist on the contained/owned POIs so a park or org page becomes a true digest of +its area, with each item labeled by the POI it came from. + +Closes #406. + +--- + +## User Stories + +### Geo-fenced rollup + +**US-026-1: News & events for everything inside a boundary** +> As a visitor, I want to click a park boundary like Sand Run and see all of the +> News and Events for every point of interest contained within that park, so that +> I get one consolidated view of what's happening in the park. + +Acceptance Criteria: +- [ ] Clicking a boundary POI shows news/events whose owning POI's location falls + inside that boundary's polygon (`ST_Contains`), plus any collected against + the boundary itself. +- [ ] Applies to all boundary types (park, municipal, county, state). +- [ ] Each rolled-up item shows the name of the POI it came from. +- [ ] The News/Events tabs appear for a boundary that has rolled-up content even + if the boundary POI itself has none. + +### Organization rollup + +**US-026-2: News & events for everything an organization owns** +> As a visitor, I want to click an organization like Summit County Metro Parks and +> see news for every park and point of interest within those parks, so that I can +> follow an entire agency from one place. + +Acceptance Criteria: +- [ ] Clicking an organization POI shows its own news/events, plus content from + POIs it owns (`owner_id`) or is associated with (`poi_associations`), plus + content from POIs geographically contained within any park boundary it owns. +- [ ] Each rolled-up item shows the name of the POI it came from. +- [ ] Tabs appear when the org has rolled-up content even with none of its own. + +### Navigation integrity + +**US-026-3: Rolled-up items link to their own POI's permalink** +> As a visitor, I want clicking a rolled-up news/event item to open that item's +> real detail page, so that the "read more" / permalink works. + +Acceptance Criteria: +- [ ] Clicking a rolled-up item navigates to `/{sourcePoiSlug}/{news|events}/{titleSlug}` + (the item's owning POI), not the boundary/org slug. + +--- + +## Data Model + +No schema changes. Uses existing structures: + +| Structure | Role | +|-----------|------| +| `pois.poi_roles` | distinguishes `boundary` / `organization` / `point` targets | +| `pois.boundary_geom` | polygon used for `ST_Contains` containment | +| `pois.geom` / `pois.geometry` | POI point used as containment test point | +| `pois.owner_id` | direct org ownership FK | +| `poi_associations(virtual_poi_id, physical_poi_id)` | org ↔ POI association | +| `poi_news.poi_id` / `poi_events.poi_id` | one-to-one item → POI link (unchanged) | + +--- + +## API Endpoints + +No new endpoints. Three existing endpoints gain rollup behavior when the target +POI is a boundary or organization (regular point POIs are unchanged): + +| Method | Path | Change | +|--------|------|--------| +| GET | `/api/pois/:id/news` | Returns news for the expanded POI-id set; adds `poi_id` + `poi_name` per item | +| GET | `/api/pois/:id/events` | Returns events for the expanded POI-id set; adds `poi_id` + `poi_name` per item | +| GET | `/api/pois/:id/tab-counts` | Counts over the expanded POI-id set | + +Expansion is automatic and role-driven — no new query parameter. + +--- + +## UI/UX Requirements + +- `PoiNews` / `PoiEvents`: when an item's `poi_id` differs from the page POI, + render a small source-POI label (the originating POI name) on the item. +- Clicks use the item's source POI name to build the permalink slug. + +--- + +## Non-Functional Requirements + +**NFR-026-1: Graceful PostGIS fallback** +- If a spatial query fails (PostGIS unavailable), rollup degrades to the + non-spatial expansion (org's own + owned/associated) and never errors the + endpoint — mirrors `getContainingBoundaries`' existing fallback. + +**NFR-026-2: No regression for point POIs** +- A regular point POI's endpoints behave exactly as today (id set is just `[id]`), + paying no spatial-query cost. + +--- + +## Dependencies + +- Depends on: PostGIS support (migration 021) and boundary geometry (spec 005 / + issue #198 imports). Builds on the same `ST_Contains` pattern as + `backend/services/geoService.js`. + +--- + +## Open Questions + +None — scope decisions resolved: all boundary types roll up; org rollup includes +POIs inside owned parks; rolled-up items are labeled with their source POI. + +--- + +## Changelog + +| Version | Date | Changes | +|---------|------|---------| +| 0.1.0 | 2026-05-27 | Initial draft | diff --git a/backend/server.js b/backend/server.js index c3d593fb..3f5d56be 100644 --- a/backend/server.js +++ b/backend/server.js @@ -75,6 +75,7 @@ import { sendWeeklyDigest, sendDigestPreviewTo, sendPersonalizedDigests } from ' import { startMcpServer } from './services/mcpServer.js'; import { initJobLogger, stopJobLogger } from './services/jobLogger.js'; import { startTracker, stopTracker, getBoatPositions } from './services/waterTaxiTrackerService.js'; +import { getRollupPoiIds } from './services/geoService.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -1895,16 +1896,18 @@ app.get('/api/pois/:id/tab-counts', async (req, res) => { const tz = (typeof rawTz === 'string' && /^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(rawTz)) ? rawTz : 'America/New_York'; + // Roll up boundary/org POIs to include contained/owned POIs (#406) + const poiIds = await getRollupPoiIds(pool, id); const tabCountsQuery = await pool.query(` SELECT (SELECT COUNT(*) FROM poi_news n - WHERE n.poi_id = $1 + WHERE n.poi_id = ANY($1) AND n.moderation_status IN ('published', 'auto_approved')) AS news_count, (SELECT COUNT(*) FROM poi_events e - WHERE e.poi_id = $1 + WHERE e.poi_id = ANY($1) AND e.moderation_status IN ('published', 'auto_approved') AND (e.start_date AT TIME ZONE $2)::date >= (CURRENT_TIMESTAMP AT TIME ZONE $2)::date) AS events_count - `, [id, tz]); + `, [poiIds, tz]); // Guard against future refactor adding a FROM clause that could return 0 rows (PR #368 review) const row = tabCountsQuery.rows[0] || { news_count: 0, events_count: 0 }; res.json({ @@ -1921,20 +1924,24 @@ app.get('/api/pois/:id/news', async (req, res) => { 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); const newsQuery = await pool.query(` SELECT n.id, n.title, n.summary, n.source_url, n.source_name, n.news_type, n.publication_date, n.date_consensus_score, n.collection_date, + n.poi_id, src.name AS poi_name, COALESCE(json_agg(json_build_object('url', u.url, 'source_name', u.source_name)) FILTER (WHERE u.id IS NOT NULL), '[]'::json) AS additional_urls FROM poi_news n LEFT JOIN poi_news_urls u ON u.news_id = n.id - WHERE n.poi_id = $1 + LEFT JOIN pois src ON src.id = n.poi_id + WHERE n.poi_id = ANY($1) AND n.moderation_status IN ('published', 'auto_approved') - GROUP BY n.id + GROUP BY n.id, src.name ORDER BY COALESCE(n.publication_date, n.collection_date) DESC, n.collection_date DESC LIMIT $2 - `, [id, limit]); + `, [poiIds, limit]); res.json(newsQuery.rows); } catch (error) { console.error('Error fetching POI news:', error); @@ -1948,20 +1955,24 @@ app.get('/api/pois/:id/events', async (req, res) => { const upcomingOnly = req.query.upcoming !== 'false'; const limit = parseInt(req.query.limit) || 50; const tz = req.query.tz || 'America/New_York'; + // Roll up boundary/org POIs to include contained/owned POIs (#406) + const poiIds = await getRollupPoiIds(pool, id); let query = ` SELECT e.id, e.title, e.description, e.start_date, e.end_date, e.event_type, e.location_details, e.source_url, e.collection_date, + e.poi_id, src.name AS poi_name, COALESCE(json_agg(json_build_object('url', u.url, 'source_name', u.source_name)) FILTER (WHERE u.id IS NOT NULL), '[]'::json) AS additional_urls FROM poi_events e LEFT JOIN poi_event_urls u ON u.event_id = e.id - WHERE e.poi_id = $1 + LEFT JOIN pois src ON src.id = e.poi_id + WHERE e.poi_id = ANY($1) AND e.moderation_status IN ('published', 'auto_approved') `; if (upcomingOnly) { query += ` AND (e.start_date AT TIME ZONE $3)::date >= (CURRENT_TIMESTAMP AT TIME ZONE $3)::date`; } - query += ` GROUP BY e.id ORDER BY e.start_date ASC LIMIT $2`; + query += ` GROUP BY e.id, src.name ORDER BY e.start_date ASC LIMIT $2`; - const eventsQuery = await pool.query(query, upcomingOnly ? [id, limit, tz] : [id, limit]); + const eventsQuery = await pool.query(query, upcomingOnly ? [poiIds, limit, tz] : [poiIds, limit]); res.json(eventsQuery.rows); } catch (error) { console.error('Error fetching POI events:', error); diff --git a/backend/services/geoService.js b/backend/services/geoService.js index bb475e4c..7f7822e2 100644 --- a/backend/services/geoService.js +++ b/backend/services/geoService.js @@ -46,3 +46,106 @@ export async function getContainingBoundaries(pool, poiId) { return []; } } + +/** + * Expand a POI id into the set of POI ids whose news/events should roll up to it. + * + * - A normal point POI returns just [id] (no spatial cost, no behavior change). + * - A boundary POI (e.g. Brecksville Reservation) adds every POI whose location + * falls inside its polygon (ST_Contains). + * - An organization POI (e.g. Cleveland Metroparks) adds the POIs it owns + * (owner_id) or is associated with (poi_associations), plus POIs geographically + * contained within any park boundary it owns. + * + * The result always includes the target id and is de-duplicated. Spatial steps + * are wrapped so a PostGIS failure degrades to the non-spatial expansion rather + * than erroring — mirroring getContainingBoundaries' graceful fallback. + * + * @param {Pool} pool - Database connection pool + * @param {number} poiId - POI id to expand + * @returns {Promise} - De-duplicated POI ids (always includes poiId) + */ +export async function getRollupPoiIds(pool, poiId) { + const id = parseInt(poiId, 10); + if (!Number.isInteger(id) || id <= 0) return []; + const ids = new Set([id]); + + let target; + try { + const targetQuery = await pool.query( + `SELECT poi_roles, (boundary_geom IS NOT NULL) AS has_boundary + FROM pois WHERE id = $1`, + [id] + ); + // Fix: non-existent POI rolls up to nothing, consistent with the invalid-input path (PR #424 review) + if (targetQuery.rows.length === 0) return []; + target = targetQuery.rows[0]; + } catch (err) { + console.warn(`[Geo] Rollup target lookup failed for POI ${id}: ${err.message}`); + return [id]; + } + + const roles = target.poi_roles || []; + const isOrg = roles.includes('organization'); + const isBoundary = roles.includes('boundary') && target.has_boundary; + + // Organization: directly owned + associated POIs (non-spatial). + let ownedIds = []; + if (isOrg) { + try { + const ownedQuery = await pool.query( + `SELECT id FROM pois + WHERE owner_id = $1 AND (deleted IS NULL OR deleted = FALSE) + UNION + SELECT physical_poi_id AS id FROM poi_associations WHERE virtual_poi_id = $1`, + [id] + ); + ownedIds = ownedQuery.rows.map(row => row.id).filter(Number.isInteger); + ownedIds.forEach(ownedId => ids.add(ownedId)); + } catch (err) { + console.warn(`[Geo] Org ownership lookup failed for POI ${id}: ${err.message}`); + } + } + + // Spatial containment: a boundary target contains POIs inside its own polygon; + // an org target contains POIs inside any park boundary it owns. + const boundarySourceIds = []; + if (isBoundary) boundarySourceIds.push(id); + if (isOrg && ownedIds.length > 0) boundarySourceIds.push(...ownedIds); + + if (boundarySourceIds.length > 0) { + try { + // Two index-friendly paths instead of one all-POIs CTE (PR #424 review): + // point POIs join directly on the indexed `geom` column (uses idx_pois_geom); + // linear features parse GeoJSON only for the small trail/boundary/river subset. + const containedQuery = await pool.query( + `WITH boundaries AS ( + SELECT boundary_geom FROM pois + WHERE id = ANY($1) + AND 'boundary' = ANY(poi_roles) + AND boundary_geom IS NOT NULL + ) + SELECT p.id + FROM pois p + JOIN boundaries b ON ST_Contains(b.boundary_geom, p.geom) + WHERE 'point' = ANY(p.poi_roles) + AND p.geom IS NOT NULL + AND (p.deleted IS NULL OR p.deleted = FALSE) + UNION + SELECT p.id + FROM pois p + JOIN boundaries b + ON ST_Contains(b.boundary_geom, ST_PointOnSurface(ST_GeomFromGeoJSON(p.geometry))) + WHERE p.poi_roles && ARRAY['trail','boundary','river']::text[] + AND p.geometry IS NOT NULL + AND (p.deleted IS NULL OR p.deleted = FALSE)`, + [boundarySourceIds] + ); + containedQuery.rows.forEach(row => { if (Number.isInteger(row.id)) ids.add(row.id); }); + } catch (err) { + console.warn(`[Geo] Containment rollup unavailable for POI ${id}: ${err.message}`); + } + } + + return Array.from(ids); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 016394e6..b5362f66 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1782,6 +1782,22 @@ body { margin: 0.25rem 0; } +.poi-event-meta { + margin-top: 0.25rem; +} + +/* Source-POI label on rolled-up boundary/org news & events (#406) */ +.poi-item-source { + display: inline-block; + font-size: 0.72rem; + color: #4a7c23; + background: #eef4e6; + border-radius: 0.75rem; + padding: 0.05rem 0.5rem; + font-weight: 500; + white-space: nowrap; +} + .sidebar-content { padding: 1.5rem; } diff --git a/frontend/src/components/sidebar/PoiEvents.jsx b/frontend/src/components/sidebar/PoiEvents.jsx index 09cdcb82..e60b622d 100644 --- a/frontend/src/components/sidebar/PoiEvents.jsx +++ b/frontend/src/components/sidebar/PoiEvents.jsx @@ -119,8 +119,10 @@ function PoiEvents({ poiId, poiName, isAdmin, editMode, onCountChange, onSelectE ) : events.map(item => (
{ - if (!poiName) return; - const poiSlug = generateSlug(poiName); + // Rolled-up items belong to a contained/owned POI — link to that POI's permalink (#406) + const sourceName = item.poi_name || poiName; + if (!sourceName) return; + const poiSlug = generateSlug(sourceName); const titleSlug = generateSlug(item.title); navigate(`/${poiSlug}/events/${titleSlug}`); if (onSelectEvent) onSelectEvent({ type: 'event', poiSlug, titleSlug }); @@ -150,6 +152,11 @@ function PoiEvents({ poiId, poiName, isAdmin, editMode, onCountChange, onSelectE Location: {item.location_details}
)} + {item.poi_name && Number(item.poi_id) !== Number(poiId) && ( +
+ 📍 {item.poi_name} +
+ )} ))} diff --git a/frontend/src/components/sidebar/PoiNews.jsx b/frontend/src/components/sidebar/PoiNews.jsx index ef7c3f86..5b5c9b9a 100644 --- a/frontend/src/components/sidebar/PoiNews.jsx +++ b/frontend/src/components/sidebar/PoiNews.jsx @@ -103,8 +103,10 @@ function PoiNews({ poiId, poiName, isAdmin, editMode, onCountChange, onSelectNew ) : news.map(item => (
{ - if (!poiName) return; - const poiSlug = generateSlug(poiName); + // Rolled-up items belong to a contained/owned POI — link to that POI's permalink (#406) + const sourceName = item.poi_name || poiName; + if (!sourceName) return; + const poiSlug = generateSlug(sourceName); const titleSlug = generateSlug(item.title); navigate(`/${poiSlug}/news/${titleSlug}`); if (onSelectNews) onSelectNews({ type: 'news', poiSlug, titleSlug }); @@ -132,6 +134,9 @@ function PoiNews({ poiId, poiName, isAdmin, editMode, onCountChange, onSelectNew )} {item.summary &&

{item.summary}

}
+ {item.poi_name && Number(item.poi_id) !== Number(poiId) && ( + 📍 {item.poi_name} + )} {item.source_name && {item.source_name}}