mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
refactor(domain): split updateFeedRecord into renameFeed and editFeed
The inPlace boolean hid two distinct intentions. Replace it with two intention-revealing operations backed by Feed.rename (presentational, never touches expiry) and Feed.edit (full edit, recomputes expiry, rejects expired). Add FeedRepository.saveConfig so these config-only edits don't re-write (and risk clobbering) the email index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,14 @@ export class FeedRepository {
|
||||
await this.putMetadata(feed.id, feed.metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist only the config. Used by the rename/edit paths where metadata is
|
||||
* unchanged — avoids re-writing (and risking clobbering) the email index.
|
||||
*/
|
||||
async saveConfig(feed: Feed): Promise<void> {
|
||||
await this.putConfig(feed.id, feed.config);
|
||||
}
|
||||
|
||||
// ── Feed config ───────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(feedId: string): Promise<FeedConfig | null> {
|
||||
|
||||
@@ -132,4 +132,46 @@ export class Feed {
|
||||
this._metadata.emails = kept;
|
||||
return { removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place edit of the presentational fields only. Never touches expiry or the
|
||||
* sender policy — used by the dashboard's minimal title/description edit.
|
||||
*/
|
||||
rename(patch: { title?: string; description?: string }): void {
|
||||
if (patch.title !== undefined) this._config.title = patch.title;
|
||||
if (patch.description !== undefined) {
|
||||
this._config.description = patch.description;
|
||||
}
|
||||
this._config.updated_at = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full edit: apply the patch and recompute expiry from `FEED_TTL_HOURS` or a
|
||||
* supplied lifetime (an absent lifetime preserves the current expiry). Rejects
|
||||
* an already-expired feed without mutating it.
|
||||
*/
|
||||
edit(patch: UpdateFeedInput, env: Env): { status: "ok" | "expired" } {
|
||||
if (this.isExpired()) return { status: "expired" };
|
||||
|
||||
const expiresAt =
|
||||
env.FEED_TTL_HOURS || patch.lifetimeHours !== undefined
|
||||
? resolveExpiresAt(env, patch.lifetimeHours)
|
||||
: this._config.expires_at;
|
||||
|
||||
if (patch.title !== undefined) this._config.title = patch.title;
|
||||
if (patch.description !== undefined) {
|
||||
this._config.description = patch.description;
|
||||
}
|
||||
if (patch.language !== undefined) this._config.language = patch.language;
|
||||
if (patch.allowedSenders !== undefined) {
|
||||
this._config.allowed_senders = patch.allowedSenders;
|
||||
}
|
||||
if (patch.blockedSenders !== undefined) {
|
||||
this._config.blocked_senders = patch.blockedSenders;
|
||||
}
|
||||
this._config.updated_at = Date.now();
|
||||
this._config.expires_at = expiresAt;
|
||||
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
|
||||
+39
-42
@@ -6,7 +6,6 @@ import { waitUntilSafe } from "../utils/worker";
|
||||
import { sendUnsubscribes } from "../utils/unsubscribe";
|
||||
import { getAttachmentBucket } from "../utils/attachments";
|
||||
import { FeedRepository } from "../domain/feed-repository";
|
||||
import { resolveExpiresAt, isExpired } from "../domain/feed";
|
||||
import {
|
||||
Feed,
|
||||
CreateFeedInput,
|
||||
@@ -52,58 +51,56 @@ export type UpdateFeedResult =
|
||||
| { status: "expired" };
|
||||
|
||||
/**
|
||||
* Apply a partial patch to a feed's config and mirror title/description/expiry
|
||||
* into the global list. Fields left undefined on `input` are preserved.
|
||||
*
|
||||
* A full edit (default) rejects expired feeds and recomputes `expires_at` from
|
||||
* `FEED_TTL_HOURS`/`lifetimeHours`. `inPlace` skips both — used by the dashboard's
|
||||
* minimal title/description edit, which must never touch expiry.
|
||||
* In-place edit of title/description only — never touches expiry. Used by the
|
||||
* dashboard's minimal edit. Mirrors the new title/description into the list.
|
||||
*/
|
||||
export async function updateFeedRecord(
|
||||
export async function renameFeed(
|
||||
env: Env,
|
||||
feedId: string,
|
||||
patch: { title?: string; description?: string },
|
||||
): Promise<UpdateFeedResult> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const feed = await repo.load(feedId);
|
||||
if (!feed) return { status: "not_found" };
|
||||
|
||||
feed.rename(patch);
|
||||
await repo.saveConfig(feed);
|
||||
await repo.updateInList(
|
||||
feedId,
|
||||
feed.config.title,
|
||||
feed.config.description,
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
return { status: "ok", config: feed.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full edit: apply the patch, recompute expiry, and reject expired feeds. Fields
|
||||
* left undefined are preserved. Mirrors title/description/expiry into the list.
|
||||
*/
|
||||
export async function editFeed(
|
||||
env: Env,
|
||||
feedId: string,
|
||||
input: UpdateFeedInput,
|
||||
options: { inPlace?: boolean } = {},
|
||||
): Promise<UpdateFeedResult> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const feed = await repo.load(feedId);
|
||||
if (!feed) return { status: "not_found" };
|
||||
|
||||
const existing = await repo.getConfig(feedId);
|
||||
|
||||
if (!existing) return { status: "not_found" };
|
||||
|
||||
if (!options.inPlace && isExpired(existing)) {
|
||||
if (feed.edit(input, env).status === "expired") {
|
||||
return { status: "expired" };
|
||||
}
|
||||
|
||||
// Full edit recomputes expiry (FEED_TTL_HOURS or a supplied lifetime resets the
|
||||
// clock; an absent lifetime preserves it). In-place edits leave expiry alone.
|
||||
const expiresAt =
|
||||
!options.inPlace &&
|
||||
(env.FEED_TTL_HOURS || input.lifetimeHours !== undefined)
|
||||
? resolveExpiresAt(env, input.lifetimeHours)
|
||||
: existing.expires_at;
|
||||
await repo.saveConfig(feed);
|
||||
await repo.updateInList(
|
||||
feedId,
|
||||
feed.config.title,
|
||||
feed.config.description,
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
const config: FeedConfig = {
|
||||
...existing,
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.description !== undefined
|
||||
? { description: input.description }
|
||||
: {}),
|
||||
...(input.language !== undefined ? { language: input.language } : {}),
|
||||
...(input.allowedSenders !== undefined
|
||||
? { allowed_senders: input.allowedSenders }
|
||||
: {}),
|
||||
...(input.blockedSenders !== undefined
|
||||
? { blocked_senders: input.blockedSenders }
|
||||
: {}),
|
||||
updated_at: Date.now(),
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
|
||||
await repo.putConfig(feedId, config);
|
||||
await repo.updateInList(feedId, config.title, config.description, expiresAt);
|
||||
|
||||
return { status: "ok", config };
|
||||
return { status: "ok", config: feed.config };
|
||||
}
|
||||
|
||||
type DeleteFeedFastResult = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from "../lib/logger";
|
||||
import { timingSafeEqual, checkProxyAuth } from "../lib/auth";
|
||||
import { Layout, clampText } from "./admin/ui";
|
||||
import { FeedRepository } from "../domain/feed-repository";
|
||||
import { updateFeedRecord } from "../lib/feed-service";
|
||||
import { renameFeed } from "../lib/feed-service";
|
||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
|
||||
import { feedsRouter } from "./admin/feeds";
|
||||
import { emailsRouter } from "./admin/emails";
|
||||
@@ -993,12 +993,7 @@ app.post(
|
||||
const { title, description } = c.req.valid("json");
|
||||
|
||||
// In-place edit: only title/description, expiry untouched.
|
||||
const result = await updateFeedRecord(
|
||||
env,
|
||||
feedId,
|
||||
{ title, description },
|
||||
{ inPlace: true },
|
||||
);
|
||||
const result = await renameFeed(env, feedId, { title, description });
|
||||
|
||||
if (result.status === "not_found") {
|
||||
return c.json({ error: "Feed not found" }, 404);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers";
|
||||
import { FeedRepository } from "../../domain/feed-repository";
|
||||
import {
|
||||
createFeedRecord,
|
||||
updateFeedRecord,
|
||||
editFeed,
|
||||
deleteFeedRecord,
|
||||
deleteFeedFastDetailed,
|
||||
} from "../../lib/feed-service";
|
||||
@@ -329,7 +329,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
blockedSenders,
|
||||
});
|
||||
|
||||
const result = await updateFeedRecord(env, feedId, {
|
||||
const result = await editFeed(env, feedId, {
|
||||
title: parsedData.title,
|
||||
description: parsedData.description,
|
||||
language: parsedData.language,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Env, FeedConfig } from "../../types";
|
||||
import { apiAuthMiddleware } from "../../lib/auth";
|
||||
import {
|
||||
createFeedRecord,
|
||||
updateFeedRecord,
|
||||
editFeed,
|
||||
deleteFeedRecord,
|
||||
} from "../../lib/feed-service";
|
||||
import { deleteAttachmentsForEmails } from "../admin/helpers";
|
||||
@@ -203,7 +203,7 @@ apiApp.openapi(
|
||||
const env = c.env;
|
||||
const { feedId } = c.req.valid("param");
|
||||
const body = c.req.valid("json");
|
||||
const result = await updateFeedRecord(env, feedId, {
|
||||
const result = await editFeed(env, feedId, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
language: body.language,
|
||||
|
||||
Reference in New Issue
Block a user