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();
|
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 { FeedState } from "./feed-state";
|
||||||
import { FeedId } from "./value-objects/feed-id";
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
import { MailboxId } from "./value-objects/mailbox-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 { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||||
import { Clock, systemClock } from "./clock";
|
import { Clock, systemClock } from "./clock";
|
||||||
import { FeedEvent } from "./events";
|
import { FeedEvent } from "./events";
|
||||||
|
import { unionNativeFeeds } from "./native-feed";
|
||||||
|
|
||||||
export interface CreateFeedInput {
|
export interface CreateFeedInput {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -57,6 +58,8 @@ export interface IngestOptions {
|
|||||||
iconDomain?: string;
|
iconDomain?: string;
|
||||||
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
||||||
unsub?: { senderKey: string; url: string };
|
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;
|
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[] {
|
allowedSenders(): string[] {
|
||||||
return [...this._state.allowedSenders];
|
return [...this._state.allowedSenders];
|
||||||
}
|
}
|
||||||
@@ -271,6 +284,19 @@ export class Feed {
|
|||||||
this._metadata.pendingConfirmation = true;
|
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({
|
this._events.push({
|
||||||
type: "EmailIngested",
|
type: "EmailIngested",
|
||||||
feedId: this.id,
|
feedId: this.id,
|
||||||
@@ -321,6 +347,11 @@ export class Feed {
|
|||||||
this._metadata.pendingConfirmation = false;
|
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
|
* The single edit path. Apply the patch (only the fields it carries) and
|
||||||
* recompute expiry when the application supplies a `Lifetime` — an absent
|
* 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
|
// ingest, lowered by an admin "dismiss" or when the last confirmation email is
|
||||||
// removed. Projected into feeds:list for the dashboard.
|
// removed. Projected into feeds:list for the dashboard.
|
||||||
pendingConfirmation?: boolean;
|
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
|
// 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)
|
mailbox_id: string; // Cached inbound address local part (admin/API display)
|
||||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||||
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
|
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)
|
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||||
|
|||||||
Reference in New Issue
Block a user