feat(admin): show email count and last-email date per feed

Surface each feed's email count on its Emails button and a "Last email …"
freshness line under the title, in both dashboard views. The values are
projected into feeds:list (kept to a single KV read) via the Feed aggregate,
so toListItemDTO now maps the whole aggregate through its intention-revealing
accessors instead of threading scalar projections. Also fixes long titles
overflowing into the Feed ID column in the table view.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-25 23:18:38 +02:00
1 parent e258206384
commit fd3ff8c40a
11 files changed
+273 -51

No files matched your search

+32 -11
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
import { FeedId } from "../domain/value-objects/feed-id";
import type { FeedConfig } from "../types";
import { Feed } from "../domain/feed.aggregate";
import type { FeedConfig, FeedMetadata } from "../types";
const fullConfig: FeedConfig = {
title: "News",
@@ -16,6 +17,13 @@ const fullConfig: FeedConfig = {
expires_at: 3000,
};
const feedFrom = (metadata: FeedMetadata) =>
Feed.reconstitute(
FeedId.unchecked("a.b.42"),
fromConfigDTO(fullConfig),
metadata,
);
describe("feed-mapper", () => {
it("round-trips a full config DTO through domain state unchanged", () => {
expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig);
@@ -32,11 +40,8 @@ describe("feed-mapper", () => {
expect(state.blockedSenders).toEqual([]);
});
it("projects the feeds:list item from domain state", () => {
const item = toListItemDTO(
FeedId.unchecked("a.b.42"),
fromConfigDTO(fullConfig),
);
it("projects the feeds:list item from an empty feed aggregate", () => {
const item = toListItemDTO(feedFrom({ emails: [] }));
expect(item).toEqual({
id: "a.b.42",
title: "News",
@@ -45,17 +50,33 @@ describe("feed-mapper", () => {
expires_at: 3000,
pendingConfirmation: false,
hasNativeFeed: false,
emailCount: 0,
lastEmailAt: undefined,
});
});
it("projects hasNativeFeed when passed", () => {
it("projects pendingConfirmation and hasNativeFeed from metadata", () => {
const item = toListItemDTO(
FeedId.unchecked("a.b.42"),
fromConfigDTO(fullConfig),
true,
true,
feedFrom({
emails: [],
pendingConfirmation: true,
nativeFeeds: { "[email protected]": [{ url: "https://x/rss", type: "rss" }] },
}),
);
expect(item.pendingConfirmation).toBe(true);
expect(item.hasNativeFeed).toBe(true);
});
it("projects email count and the newest email's timestamp", () => {
const item = toListItemDTO(
feedFrom({
emails: [
{ key: "k2", subject: "b", receivedAt: 1700000000000 },
{ key: "k1", subject: "a", receivedAt: 1600000000000 },
],
}),
);
expect(item.emailCount).toBe(2);
expect(item.lastEmailAt).toBe(1700000000000);
});
});
+18 -15
View File
@@ -1,6 +1,6 @@
import { FeedConfig, FeedListItem } from "../types";
import { FeedState } from "../domain/feed-state";
import { FeedId } from "../domain/value-objects/feed-id";
import { Feed } from "../domain/feed.aggregate";
/**
* The translation seam between the Feed aggregate's domain state (camelCase) and
@@ -44,20 +44,23 @@ export function toConfigDTO(state: FeedState): FeedConfig {
};
}
/** Domain state → the projection cached in the global `feeds:list` registry. */
export function toListItemDTO(
id: FeedId,
state: FeedState,
pendingConfirmation = false,
hasNativeFeed = false,
): FeedListItem {
/**
* The Feed aggregate → the projection cached in the global `feeds:list` registry.
* Unlike the config DTO, the list item is a read-model view: it folds in the
* aggregate's metadata-derived signals (pending confirmation, native feed,
* email count/last-received) alongside the config fields, so it reads the whole
* aggregate through its intention-revealing accessors.
*/
export function toListItemDTO(feed: Feed): FeedListItem {
return {
id: id.value,
title: state.title,
description: state.description,
mailbox_id: state.mailboxId,
expires_at: state.expiresAt,
pendingConfirmation,
hasNativeFeed,
id: feed.id.value,
title: feed.title,
description: feed.description,
mailbox_id: feed.mailboxId.value,
expires_at: feed.expiresAt,
pendingConfirmation: feed.pendingConfirmation,
hasNativeFeed: feed.hasNativeFeed(),
emailCount: feed.emailCount,
lastEmailAt: feed.lastEmailAt,
};
}
+3 -24
View File
@@ -87,14 +87,7 @@ export class FeedRepository {
await Promise.all([
this.putConfig(feed.id, toConfigDTO(feed.state())),
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(
toListItemDTO(
feed.id,
feed.state(),
feed.pendingConfirmation,
feed.hasNativeFeed(),
),
),
this.upsertListEntry(toListItemDTO(feed)),
this.putInboundIndex(feed.mailboxId, feed.id),
]);
}
@@ -108,14 +101,7 @@ export class FeedRepository {
async saveMetadata(feed: Feed): Promise<void> {
await Promise.all([
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(
toListItemDTO(
feed.id,
feed.state(),
feed.pendingConfirmation,
feed.hasNativeFeed(),
),
),
this.upsertListEntry(toListItemDTO(feed)),
]);
}
@@ -127,14 +113,7 @@ export class FeedRepository {
async saveConfig(feed: Feed): Promise<void> {
await Promise.all([
this.putConfig(feed.id, toConfigDTO(feed.state())),
this.upsertListEntry(
toListItemDTO(
feed.id,
feed.state(),
feed.pendingConfirmation,
feed.hasNativeFeed(),
),
),
this.upsertListEntry(toListItemDTO(feed)),
this.putInboundIndex(feed.mailboxId, feed.id),
]);
}