mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(domain): pendingConfirmation flag on the Feed aggregate
This commit is contained in:
@@ -233,6 +233,82 @@ describe("Feed events", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function newFeed(): Feed {
|
||||||
|
return Feed.create(
|
||||||
|
FeedId.generate(),
|
||||||
|
{
|
||||||
|
title: "T",
|
||||||
|
description: "",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("alpha.beta.10") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmationEmail(
|
||||||
|
key: string,
|
||||||
|
confirmation?: { links: string[] },
|
||||||
|
): EmailMetadata {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
subject: "s",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
size: 10,
|
||||||
|
...(confirmation ? { confirmation } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Feed pendingConfirmation", () => {
|
||||||
|
it("is false on a fresh feed", () => {
|
||||||
|
expect(newFeed().pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is raised when a confirmation email is ingested", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(feed.pendingConfirmation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays false for a non-confirmation email", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1"), { maxBytes: 1_000_000 });
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is cleared by dismissConfirmation", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-raise after dismiss when removing an unrelated email", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.ingest(confirmationEmail("k2"), { maxBytes: 1_000_000 });
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
feed.removeEmails(["k2"]);
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears when the last confirmation email is removed", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.removeEmails(["k1"]);
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FeedRepository.load / save round-trip", () => {
|
describe("FeedRepository.load / save round-trip", () => {
|
||||||
it("persists a created feed and reflects later mutations", async () => {
|
it("persists a created feed and reflects later mutations", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ export class Feed {
|
|||||||
return this._metadata.iconDomain;
|
return this._metadata.iconDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while at least one unactioned confirmation email is present. */
|
||||||
|
get pendingConfirmation(): boolean {
|
||||||
|
return this._metadata.pendingConfirmation ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
allowedSenders(): string[] {
|
allowedSenders(): string[] {
|
||||||
return [...this._state.allowedSenders];
|
return [...this._state.allowedSenders];
|
||||||
}
|
}
|
||||||
@@ -258,6 +263,10 @@ export class Feed {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.confirmation) {
|
||||||
|
this._metadata.pendingConfirmation = true;
|
||||||
|
}
|
||||||
|
|
||||||
this._events.push({
|
this._events.push({
|
||||||
type: "EmailIngested",
|
type: "EmailIngested",
|
||||||
feedId: this.id,
|
feedId: this.id,
|
||||||
@@ -295,9 +304,19 @@ export class Feed {
|
|||||||
(target.has(entry.key) ? removed : kept).push(entry);
|
(target.has(entry.key) ? removed : kept).push(entry);
|
||||||
}
|
}
|
||||||
this._metadata.emails = kept;
|
this._metadata.emails = kept;
|
||||||
|
// Lower-only: clear when no confirmation email remains. Never re-raise here,
|
||||||
|
// so an admin "dismiss" survives deletion of unrelated emails.
|
||||||
|
if (!kept.some((e) => e.confirmation)) {
|
||||||
|
this._metadata.pendingConfirmation = false;
|
||||||
|
}
|
||||||
return { removed };
|
return { removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark the pending confirmation as handled — "stop reminding me". */
|
||||||
|
dismissConfirmation(): void {
|
||||||
|
this._metadata.pendingConfirmation = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export interface FeedMetadata {
|
|||||||
// RFC 8058 one-click unsubscribe URLs, keyed by sender so each newsletter on
|
// RFC 8058 one-click unsubscribe URLs, keyed by sender so each newsletter on
|
||||||
// the feed keeps its own (latest) link; fired when the feed is deleted.
|
// the feed keeps its own (latest) link; fired when the feed is deleted.
|
||||||
unsubscribe?: Record<string, string>;
|
unsubscribe?: Record<string, string>;
|
||||||
|
// True while at least one unactioned confirmation email is present. Raised on
|
||||||
|
// ingest, lowered by an admin "dismiss" or when the last confirmation email is
|
||||||
|
// removed. Projected into feeds:list for the dashboard.
|
||||||
|
pendingConfirmation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email metadata interface (summary info for listing)
|
// Email metadata interface (summary info for listing)
|
||||||
@@ -73,6 +77,9 @@ export interface EmailMetadata {
|
|||||||
inlineAttachmentIds?: string[]; // Inline images: hidden from lists, still cleaned up
|
inlineAttachmentIds?: string[]; // Inline images: hidden from lists, still cleaned up
|
||||||
messageId?: string; // RFC 2822 Message-ID header (dedup primary key)
|
messageId?: string; // RFC 2822 Message-ID header (dedup primary key)
|
||||||
dedupHash?: string; // SHA-256 hex of normalized subject+content (dedup fallback)
|
dedupHash?: string; // SHA-256 hex of normalized subject+content (dedup fallback)
|
||||||
|
// Detected subscription-confirmation links (ranked top-3). Present ⇒ the email
|
||||||
|
// was detected as a confirmation request.
|
||||||
|
confirmation?: { links: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed list interface
|
// Feed list interface
|
||||||
@@ -87,6 +94,7 @@ export interface FeedListItem {
|
|||||||
description?: string;
|
description?: string;
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cumulative monitoring counters (persisted as a KV singleton)
|
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||||
|
|||||||
Reference in New Issue
Block a user