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:
Julien Herr
2026-05-25 17:14:38 +02:00
parent 86d18eb390
commit dc2ccfdd1c
3 changed files with 112 additions and 1 deletions
+73
View File
@@ -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" },
]);
});
});
+32 -1
View File
@@ -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
+7
View File
@@ -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)