Files
weatherapi/docs/roadmap/implementation.md
Eric Rakestraw 6316783c3a
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
Record active alert verification
2026-06-12 14:11:42 +00:00

11 KiB

Active Alert Filtering Cleanup

Status

Implementation, current-behavior documentation, and automated verification are complete.

Verification Record

Completed on 2026-06-12:

  • go test ./internal/app
  • go test ./internal/adapters/inbound/httpapi
  • go test ./internal/adapters/inbound/httpapi/presenter
  • go test ./...

Deployment smoke checks were not run from this workspace because the local repository state is not a deployed weatherapi instance.

Summary

Fix /alerts/active so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.

The current implementation reads the latest alert_runs parent row, loads all child alerts, and presents the run unchanged. This can expose expired alerts when the latest persisted weatherfeeder snapshot is stale or when a snapshot contains alerts that later expire before the next successful ingestion cycle.

This cleanup preserves the existing route, response envelope, repository contract, and weatherfeeder table ownership. The change should be implemented as app-layer filtering over the latest stored snapshot, following the same architectural pattern already used by convective outlook active filtering.

Current Behavior To Correct

  • GET /alerts/active calls LatestAlertRun(ctx).
  • LatestAlertRun(ctx) returns the latest stored alert snapshot from Postgres.
  • All child alerts for that run are returned unchanged.
  • The endpoint does not compare effective, onset, expires, status, or messageType to the request time.
  • Expired alerts can therefore appear under an endpoint named /alerts/active.

Target Behavior

  • GET /alerts/active returns the latest stored alert run with alerts filtered to items active at request time.
  • Missing latest alert run still returns { "data": null }.
  • A latest run with no currently active alerts returns a non-null run object with alerts: [].
  • The repository remains a latest-snapshot reader and does not own active-time policy.
  • The app service owns alert active filtering policy.
  • The HTTP adapter supplies the request-time instant using an injectable clock for tests.
  • Presentation remains responsible only for response shape, units no-op behavior, and rendering.

Active Alert Policy

Use a single app-layer predicate for determining whether an alert is active at an instant activeAt.

An alert is active when all of the following are true:

  • messageType is not Cancel, case-insensitive after trimming.
  • effective is absent or effective <= activeAt.
  • expires is absent or activeAt < expires.

Additional policy notes:

  • Do not use onset as a required active boundary. onset can describe hazard onset and may be later than alert effective time; using it as a hard lower bound could hide valid watches, warnings, or advisories that are already in effect from an alerting perspective.
  • Do not filter by status in the first cleanup unless existing model/test data proves a specific non-active status must be excluded. NWS active feeds commonly use Actual; persisted historical snapshots may include other values, but time and cancellation policy are the high-confidence active criteria.
  • Treat nil expires as active if the other criteria pass. This preserves data when an upstream alert omits an expiration, while still allowing future tightening if real data shows nil expiration should be suppressed.
  • Preserve input alert order after filtering.
  • Preserve run metadata such as asOf, location fields, latitude, and longitude even when all alerts are filtered out.

Public API Impact

  • Route remains GET /alerts/active.
  • Supported query parameters remain format and units.
  • precision, tz / TZ, and unknown query parameters remain rejected unless a separate roadmap explicitly changes alert query support.
  • JSON/XML/text format support remains unchanged.
  • Response envelope remains { "data": ... }.
  • No Postgres schema or weatherfeeder migration is required.

Stage 1: App Service Filtering

Changes

  • Keep app.Repository.LatestAlertRun(ctx) unchanged.
  • Add an app-level alert read use case, either:
    • LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error), or
    • LatestAlerts(ctx context.Context, filter AlertFilter) (*model.WeatherAlertRun, error) with AlertFilter.ActiveAt.
  • Prefer the first option unless another alert route is being added at the same time; it is narrower and clearer for the existing API.
  • Implement the new service method by:
    • reading s.repo.LatestAlertRun(ctx);
    • returning nil, nil when no run exists;
    • cloning the run before mutation;
    • filtering cloned Alerts with the active predicate;
    • returning the cloned run.
  • Add package-local helpers for:
    • cloning WeatherAlertRun;
    • cloning WeatherAlert values deeply enough to avoid mutating repository-owned slices;
    • copying References slices;
    • evaluating isActiveAlert(alert, activeAt).

Clone Requirements

The clone must protect repository-returned data from service mutation:

  • allocate a new Alerts slice;
  • allocate new References slices for each alert;
  • copy pointer time fields only if the model uses pointer fields for alert timestamps;
  • preserve string, boolean, enum, and scalar values exactly.

Tests

Add app tests covering:

  • repository delegation and error propagation;
  • nil, nil when the repository has no latest alert run;
  • expired alerts are removed when activeAt >= expires;
  • alerts expiring exactly at activeAt are inactive;
  • alerts effective exactly at activeAt are active;
  • future-effective alerts are inactive;
  • missing effective does not make an otherwise valid alert inactive;
  • missing expires does not make an otherwise valid alert inactive;
  • messageType=Cancel is excluded case-insensitively;
  • alert order is preserved;
  • run metadata is preserved when all alerts are filtered out;
  • filtering does not mutate the repository-owned run, alerts, references, or timestamp pointers.

Verification

go test ./internal/app

Stage 2: HTTP Adapter Wiring

Changes

  • Extend internal/adapters/inbound/httpapi.Service with the app service method selected in Stage 1.
  • Add adapter-local clock injection near alerts_endpoint.go:
var alertNow = time.Now
  • Update /alerts/active handler to call the new active-alert service method with alertNow().UTC().
  • Keep bindQuery as the binder so public query support remains format and units only.
  • Keep the text template name alerts_active.txt.tmpl.
  • Do not add timezone support in this cleanup. Alert timestamp presentation currently returns canonical model timestamps; timezone support would be a separate public API expansion.

Tests

Update HTTP tests covering:

  • route registration remains unchanged;
  • handler passes alertNow().UTC() to the service;
  • JSON success response renders filtered service output;
  • text output renders zero active alerts as Alerts: 0 when data is non-null;
  • data: null still renders no-data behavior;
  • units=us remains accepted and has no schema effect;
  • precision, tz, TZ, and unknown query params still return 400.

Verification

go test ./internal/adapters/inbound/httpapi

Stage 3: Presenter And Template Check

Changes

  • Keep presenter.AlertsPayload as a pass-through unless filtering requires defensive copy behavior at presentation time.
  • Do not move active filtering into the presenter.
  • Review templates/alerts_active.txt.tmpl and ensure it behaves correctly when Data is non-null and Alerts is empty.
  • If needed, add an explicit text fixture/assertion rather than changing template wording broadly.

Tests

Add or update presenter/template-sensitive tests only if current endpoint tests do not prove:

  • nil data renders no-data text;
  • non-null data with empty alerts renders an active-alert run with Alerts: 0;
  • expired/canceled alerts do not appear in text output after service filtering.

Verification

go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter

Stage 4: Documentation Updates

Changes

Update current-behavior docs after implementation is complete:

  • docs/api.md:
    • clarify that /alerts/active returns the latest alert run filtered to alerts active at request time;
    • document that no current active alerts returns a run with alerts: [] when a latest run exists;
    • keep data: null limited to no stored alert run;
    • keep supported query params as format and units.
  • README.md if endpoint summaries mention active alerts.
  • docs/internal/postgres-repository.md:
    • clarify that LatestAlertRun reads the latest stored snapshot and active filtering is performed in the app service.
  • docs/policy/architecture.md only if its latest-resource wording needs to distinguish latest snapshots from request-time derived active views.

Do not document this unimplemented behavior outside docs/roadmap/ before the code change lands.

Tests

No doc-specific tests are required unless existing docs consistency tests cover endpoint summaries.

Stage 5: Final Verification

Run focused tests:

go test ./internal/app ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter

Run the full suite:

go test ./...

Manual smoke checks after deployment:

curl 'https://weather.api.rakestrawhome.com/alerts/active?format=json'
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=text'

Expected behavior when all alerts in the latest stored run are expired:

  • JSON returns a non-null data object with alerts: [].
  • Text output shows the run metadata and Alerts: 0.

Guardrails

  • Do not change weatherfeeder ingestion behavior in this cleanup.
  • Do not alter Postgres tables or SQL filtering unless a later performance issue justifies it.
  • Do not make weatherapi poll NWS or any upstream provider.
  • Do not rename /alerts/active.
  • Do not add alert history endpoints in this cleanup.
  • Do not introduce generic filtering frameworks; use small app-local helpers.
  • Do not move route query validation into the app layer.
  • Do not move active filtering into presenters or text templates.

Assumptions

  • weatherfeeder persists alert snapshots that were active at ingestion time, but weatherapi must still enforce request-time active semantics.
  • Cancellation messages are operational metadata, not active hazards, and should not be returned by /alerts/active.
  • Missing expires should not suppress an alert unless future real-world data shows this creates stale records.
  • Time comparisons should use UTC instants; timezone presentation is not part of this cleanup.