Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions .specify/specs/026-geofenced-news/plan.md
Original file line number Diff line number Diff line change
@@ -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 |
136 changes: 136 additions & 0 deletions .specify/specs/026-geofenced-news/spec.md
Original file line number Diff line number Diff line change
@@ -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 |
29 changes: 20 additions & 9 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Comment on lines 1924 to +1928
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);

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);
Expand All @@ -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);
Expand Down
Loading
Loading