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:
1 parent
e258206384
commit
fd3ff8c40a
11 files changed
+273
-51
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user