mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(domain): store native feeds per-sender on the Feed aggregate
Add nativeFeeds/nativeFeedDismissed to FeedMetadata and hasNativeFeed to FeedListItem; extend IngestOptions with nativeFeeds; add nativeFeeds(), hasNativeFeed(), and dismissNativeFeed() to the Feed aggregate mirroring the existing pendingConfirmation/dismissConfirmation pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -338,3 +338,76 @@ describe("FeedRepository.load / save round-trip", () => {
|
||||
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed native feeds", () => {
|
||||
const nf = (
|
||||
senderKey: string,
|
||||
url: string,
|
||||
type: "rss" | "atom" | "json",
|
||||
) => ({
|
||||
maxBytes: 1_000_000_000,
|
||||
nativeFeeds: { senderKey, feeds: [{ url, type }] },
|
||||
});
|
||||
|
||||
it("stores native feeds and raises the flag on ingest", () => {
|
||||
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||
feed.ingest(entry(), nf("a@x.com", "https://x.com/rss", "rss"));
|
||||
expect(feed.nativeFeeds()).toEqual([
|
||||
{ url: "https://x.com/rss", type: "rss" },
|
||||
]);
|
||||
expect(feed.hasNativeFeed()).toBe(true);
|
||||
});
|
||||
|
||||
it("latest non-empty wins per sender; other senders preserved", () => {
|
||||
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||
feed.ingest(
|
||||
entry({ key: "k1" }),
|
||||
nf("a@x.com", "https://x.com/old", "rss"),
|
||||
);
|
||||
feed.ingest(
|
||||
entry({ key: "k2" }),
|
||||
nf("b@y.com", "https://y.com/atom", "atom"),
|
||||
);
|
||||
feed.ingest(
|
||||
entry({ key: "k3" }),
|
||||
nf("a@x.com", "https://x.com/new", "rss"),
|
||||
);
|
||||
expect(feed.nativeFeeds()).toEqual([
|
||||
{ url: "https://x.com/new", type: "rss" },
|
||||
{ url: "https://y.com/atom", type: "atom" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dismiss hides the notice but keeps URLs; only a new URL re-raises", () => {
|
||||
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||
feed.ingest(
|
||||
entry({ key: "k1" }),
|
||||
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||
);
|
||||
feed.dismissNativeFeed();
|
||||
expect(feed.hasNativeFeed()).toBe(false);
|
||||
expect(feed.nativeFeeds()).toHaveLength(1);
|
||||
feed.ingest(
|
||||
entry({ key: "k2" }),
|
||||
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||
);
|
||||
expect(feed.hasNativeFeed()).toBe(false); // same URL → stays dismissed
|
||||
feed.ingest(
|
||||
entry({ key: "k3" }),
|
||||
nf("a@x.com", "https://x.com/rss2", "rss"),
|
||||
);
|
||||
expect(feed.hasNativeFeed()).toBe(true); // new URL → re-raise
|
||||
});
|
||||
|
||||
it("removeEmails leaves native feeds intact", () => {
|
||||
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||
feed.ingest(
|
||||
entry({ key: "k1" }),
|
||||
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||
);
|
||||
feed.removeEmails(["k1"]);
|
||||
expect(feed.nativeFeeds()).toEqual([
|
||||
{ url: "https://x.com/rss", type: "rss" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedMetadata, EmailMetadata } from "../types";
|
||||
import { FeedMetadata, EmailMetadata, NativeFeed } from "../types";
|
||||
import { FeedState } from "./feed-state";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import { MailboxId } from "./value-objects/mailbox-id";
|
||||
@@ -6,6 +6,7 @@ import { Lifetime } from "./value-objects/lifetime";
|
||||
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||
import { Clock, systemClock } from "./clock";
|
||||
import { FeedEvent } from "./events";
|
||||
import { unionNativeFeeds } from "./native-feed";
|
||||
|
||||
export interface CreateFeedInput {
|
||||
title: string;
|
||||
@@ -57,6 +58,8 @@ export interface IngestOptions {
|
||||
iconDomain?: string;
|
||||
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
||||
unsub?: { senderKey: string; url: string };
|
||||
/** Native syndication feeds the sender advertised, keyed by sender. */
|
||||
nativeFeeds?: { senderKey: string; feeds: NativeFeed[] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +167,16 @@ export class Feed {
|
||||
return this._metadata.pendingConfirmation ?? false;
|
||||
}
|
||||
|
||||
/** Discovered native feeds (Atom/RSS/JSON), union across senders, deduped. */
|
||||
nativeFeeds(): NativeFeed[] {
|
||||
return unionNativeFeeds(this._metadata.nativeFeeds);
|
||||
}
|
||||
|
||||
/** True when a native feed was discovered and the notice was not dismissed. */
|
||||
hasNativeFeed(): boolean {
|
||||
return this.nativeFeeds().length > 0 && !this._metadata.nativeFeedDismissed;
|
||||
}
|
||||
|
||||
allowedSenders(): string[] {
|
||||
return [...this._state.allowedSenders];
|
||||
}
|
||||
@@ -271,6 +284,19 @@ export class Feed {
|
||||
this._metadata.pendingConfirmation = true;
|
||||
}
|
||||
|
||||
if (opts.nativeFeeds && opts.nativeFeeds.feeds.length > 0) {
|
||||
const known = new Set(this.nativeFeeds().map((f) => f.url));
|
||||
this._metadata.nativeFeeds = {
|
||||
...(this._metadata.nativeFeeds ?? {}),
|
||||
[opts.nativeFeeds.senderKey]: opts.nativeFeeds.feeds,
|
||||
};
|
||||
// Re-raise the notice only when a genuinely new URL appears, so a dismiss
|
||||
// survives the same feed being re-advertised on every subsequent email.
|
||||
if (opts.nativeFeeds.feeds.some((f) => !known.has(f.url))) {
|
||||
this._metadata.nativeFeedDismissed = false;
|
||||
}
|
||||
}
|
||||
|
||||
this._events.push({
|
||||
type: "EmailIngested",
|
||||
feedId: this.id,
|
||||
@@ -321,6 +347,11 @@ export class Feed {
|
||||
this._metadata.pendingConfirmation = false;
|
||||
}
|
||||
|
||||
/** Mark the native-feed notice as handled — "stop reminding me". */
|
||||
dismissNativeFeed(): void {
|
||||
this._metadata.nativeFeedDismissed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single edit path. Apply the patch (only the fields it carries) and
|
||||
* recompute expiry when the application supplies a `Lifetime` — an absent
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface FeedMetadata {
|
||||
// ingest, lowered by an admin "dismiss" or when the last confirmation email is
|
||||
// removed. Projected into feeds:list for the dashboard.
|
||||
pendingConfirmation?: boolean;
|
||||
// Native syndication feeds (Atom/RSS/JSON) senders advertised via
|
||||
// <link rel="alternate">, keyed by sender. Latest non-empty per sender wins.
|
||||
nativeFeeds?: Record<string, NativeFeed[]>;
|
||||
// True when the admin dismissed the native-feed notice; suppresses the
|
||||
// dashboard pill while the URLs stay available in the feed detail view.
|
||||
nativeFeedDismissed?: boolean;
|
||||
}
|
||||
|
||||
// A syndication feed a newsletter advertises about itself (via
|
||||
@@ -104,6 +110,7 @@ export interface FeedListItem {
|
||||
mailbox_id: string; // Cached inbound address local part (admin/API display)
|
||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
|
||||
hasNativeFeed?: boolean; // Projected from FeedMetadata for the dashboard pill
|
||||
}
|
||||
|
||||
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||
|
||||
Reference in New Issue
Block a user