diff --git a/src/domain/feed-repository.ts b/src/domain/feed-repository.ts index d0ea121..378e355 100644 --- a/src/domain/feed-repository.ts +++ b/src/domain/feed-repository.ts @@ -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 { + await this.putConfig(feed.id, feed.config); + } + // ── Feed config ─────────────────────────────────────────────────────────── async getConfig(feedId: string): Promise { diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index d75cf88..6db319c 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -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" }; + } } diff --git a/src/lib/feed-service.ts b/src/lib/feed-service.ts index 893eed7..f8d4a3c 100644 --- a/src/lib/feed-service.ts +++ b/src/lib/feed-service.ts @@ -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 { + 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 { 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 = { diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 031f24a..2d5e9d2 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -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); diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index a91d9b3..b0cc686 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -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, diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 5c6c5b6..390fa42 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -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,