Task 16 — daily digest (news-server/src/notify/digest.rs) ========================================================== What was built -------------- - NEW crates/news-server/src/notify/digest.rs - pub async fn build_and_send_digest(pool, scorer, ntfy, config, now) - Due-time gate: today's 07:00 in now.timezone(); before that -> Ok(()) debug log. - Idempotency gate: SELECT COUNT(*) FROM notification_log WHERE lane = 'digest' AND created_at >= ?1 (today's 07:00 UTC RFC3339). - Queries story_clusters created in the last 24h joined to canonical raw_items. - Scores canonical items, skips blocklisted (NEG_INFINITY) and opinion pieces when notify_opinions = false. - Band check: score in [p75, p90) of the trailing-7-day relevance distribution via PercentileTracker. - Sends one ntfy request via NtfyPublisher::publish_digest with Title, Priority: 2, Markdown: yes, Tags: newspaper, bullet-list body. - Records one notification_log row with Uuid::nil(), lane='digest', source_count_at_notify=, dry_run=0. - No token-bucket interaction. - MODIFIED crates/news-server/src/notify/ntfy.rs - Added NotifyError::{Store, Sql, Server} #[from] variants. - Added pub async fn publish_digest(title, body_markdown). - Factored retry-once logic into private send_with_retry(Fn() -> RequestBuilder) so publish() behavior/tests are unchanged. - MODIFIED crates/news-server/src/notify/mod.rs - Added pub mod digest. Test names added ---------------- notify::digest::tests:: - skips_when_no_eligible_stories - sends_digest_with_in_band_stories - second_call_same_day_is_no_op - before_seven_am_is_no_op - excludes_opinion_when_notify_opinions_false - includes_opinion_when_notify_opinions_true Opinion flagging representation ------------------------------- - news-core/src/model.rs: RawItem.kind is ItemKind::{News, Opinion}. - news-store/migrations/0001_init.sql: raw_items.kind TEXT NOT NULL stores "news" or "opinion". - The digest filters on the joined canonical raw_items.kind column; no new migration or column was needed. Verification ============ $ cargo test -p news-server ... running 26 tests ... test notify::digest::tests::skips_when_no_eligible_stories ... ok test notify::digest::tests::excludes_opinion_when_notify_opinions_false ... ok test notify::digest::tests::includes_opinion_when_notify_opinions_true ... ok test notify::digest::tests::sends_digest_with_in_band_stories ... ok test notify::digest::tests::before_seven_am_is_no_op ... ok test notify::digest::tests::second_call_same_day_is_no_op ... ok ... test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out $ cargo fmt --all --check (no output, exit 0) $ cargo clippy --workspace --all-targets -- -D warnings Checking news-server v0.1.0 (/home/connor/docs/projects/news/crates/news-server) Finished dev profile [unoptimized + debuginfo] target(s) in 5.29s Captured happy-test ntfy request body ------------------------------------- (temporarily instrumented sends_digest_with_in_band_stories to eprintln the wiremock request body, then removed the instrumentation) CAPTURED_DIGEST_BODY: - [Story 2](https://example.test/2) - [Story 1](https://example.test/1) - [Story 0](https://example.test/0) The single POST to /news-triage carried: Title: Daily digest: 3 stories Priority: 2 Markdown: yes Tags: newspaper body as shown above.