fix: soft delete blogs to prevent sync from recreating deleted entries

Deleted blogs are now marked with status: "deleted" instead of being
removed from MongoDB. The upsertBlog function skips deleted feedUrls,
preventing OPML/Microsub sync from recreating them. All queries exclude
deleted blogs. Flash messages now use Indiekit's native notificationBanner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-09 12:23:28 +01:00
parent d7f2344c4b
commit c2074ffde5
5 changed files with 55 additions and 18 deletions

View File

@@ -38,6 +38,9 @@ async function list(request, response) {
// Get unique categories for filter dropdown
const categories = [...new Set(blogs.map((b) => b.category).filter(Boolean))];
// Extract flash messages for native Indiekit notification banner
const flash = consumeFlashMessage(request);
response.render("blogroll-blogs", {
title: request.__("blogroll.blogs.title"),
blogs: filteredBlogs,
@@ -45,6 +48,7 @@ async function list(request, response) {
filterCategory: category,
filterStatus,
baseUrl: request.baseUrl,
...flash,
});
} catch (error) {
console.error("[Blogroll] Blogs list error:", error);
@@ -166,12 +170,16 @@ async function edit(request, response) {
: item.published,
}));
// Extract flash messages for native Indiekit notification banner
const flash = consumeFlashMessage(request);
response.render("blogroll-blog-edit", {
title: request.__("blogroll.blogs.edit"),
blog,
items,
isNew: false,
baseUrl: request.baseUrl,
...flash,
});
} catch (error) {
console.error("[Blogroll] Edit blog error:", error);
@@ -294,6 +302,21 @@ async function refresh(request, response) {
}
}
/**
* Extract and clear flash messages from session
* Returns { success, error } for Indiekit's native notificationBanner
*/
function consumeFlashMessage(request) {
const result = {};
if (request.session?.messages?.length) {
const msg = request.session.messages[0];
if (msg.type === "success") result.success = msg.content;
else if (msg.type === "error" || msg.type === "warning") result.error = msg.content;
request.session.messages = null;
}
return result;
}
export const blogsController = {
list,
newForm,

View File

@@ -25,7 +25,7 @@ export async function getBlogs(application, options = {}) {
const collection = getCollection(application);
const { category, sourceId, includeHidden = false, limit = 100, offset = 0 } = options;
const query = {};
const query = { status: { $ne: "deleted" } };
if (!includeHidden) query.hidden = { $ne: true };
if (category) query.category = category;
if (sourceId) query.sourceId = new ObjectId(sourceId);
@@ -48,7 +48,7 @@ export async function countBlogs(application, options = {}) {
const collection = getCollection(application);
const { category, includeHidden = false } = options;
const query = {};
const query = { status: { $ne: "deleted" } };
if (!includeHidden) query.hidden = { $ne: true };
if (category) query.category = category;
@@ -75,7 +75,7 @@ export async function getBlog(application, id) {
*/
export async function getBlogByFeedUrl(application, feedUrl) {
const collection = getCollection(application);
return collection.findOne({ feedUrl });
return collection.findOne({ feedUrl, status: { $ne: "deleted" } });
}
/**
@@ -142,7 +142,8 @@ export async function updateBlog(application, id, data) {
}
/**
* Delete a blog and its items
* Delete a blog and its items (soft delete)
* Marks blog as deleted so sync won't recreate it.
* @param {object} application - Application instance
* @param {string|ObjectId} id - Blog ID
* @returns {Promise<boolean>} Success
@@ -154,9 +155,19 @@ export async function deleteBlog(application, id) {
// Delete items for this blog
await db.collection("blogrollItems").deleteMany({ blogId: objectId });
// Delete the blog
const result = await db.collection("blogrollBlogs").deleteOne({ _id: objectId });
return result.deletedCount > 0;
// Soft delete: mark as deleted so upsertBlog won't recreate it
const result = await db.collection("blogrollBlogs").updateOne(
{ _id: objectId },
{
$set: {
status: "deleted",
hidden: true,
deletedAt: new Date(),
updatedAt: new Date(),
},
}
);
return result.modifiedCount > 0;
}
/**
@@ -204,6 +215,7 @@ export async function getBlogsDueForRefresh(application, maxAge = 60) {
return collection
.find({
hidden: { $ne: true },
status: { $ne: "deleted" },
$or: [{ lastFetchAt: null }, { lastFetchAt: { $lt: cutoff } }],
})
.toArray();
@@ -219,7 +231,7 @@ export async function getCategories(application) {
return collection
.aggregate([
{ $match: { hidden: { $ne: true }, category: { $ne: "" } } },
{ $match: { hidden: { $ne: true }, status: { $ne: "deleted" }, category: { $ne: "" } } },
{ $group: { _id: "$category", count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
])
@@ -236,6 +248,15 @@ export async function upsertBlog(application, data) {
const collection = getCollection(application);
const now = new Date();
// Skip if a blog with this feedUrl was soft-deleted
const deleted = await collection.findOne({
feedUrl: data.feedUrl,
status: "deleted",
});
if (deleted) {
return { upserted: false, modified: false, skippedDeleted: true };
}
const filter = { feedUrl: data.feedUrl };
if (data.sourceId) {
filter.sourceId = new ObjectId(data.sourceId);

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-blogroll",
"version": "1.0.9",
"version": "1.0.10",
"description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
"keywords": [
"indiekit",

View File

@@ -203,11 +203,7 @@
<h1 class="page-header__title">{{ title }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
{# Flash messages rendered by Indiekit's native notificationBanner via success/error template vars #}
<form method="post" action="{% if isNew %}{{ baseUrl }}/blogs{% else %}{{ baseUrl }}/blogs/{{ blog._id }}{% endif %}" class="br-form">
{% if isNew %}

View File

@@ -112,10 +112,7 @@
<h1 class="page-header__title">{{ __("blogroll.blogs.title") }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{# Flash messages are now rendered by Indiekit's native notificationBanner via success/error template vars #}
{% endfor %}
<div class="br-blogs">