Skip to main content

max / makenotwork

Add soft delete with 7-day recovery and wishlist/bookmark Two features completing the UX audit LOW items: Soft delete: - Items now soft-deleted (deleted_at column) instead of hard-deleted - Auto-purged after 7 days by scheduler daily job - Restore endpoint: POST /api/items/{id}/restore - Discover and project listings filter out soft-deleted items - Public item pages return 404 for soft-deleted (unless owner) - Migration 088 Wishlist: - New wishlists table (user_id, item_id, unique) - Toggle endpoint: POST /api/wishlists/{item_id} - "Wishlist" link on item pages (logged-in non-owners) - Bold text when wishlisted, click to toggle
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 03:22 UTC
Commit: a4d6be1369944d4379c57a1e70bfdbe73617c6d5
Parent: 687492d
17 files changed, +273 insertions, -30 deletions
@@ -55,8 +55,8 @@
55 55
56 56 - [x] **[LOW]** Add bulk operations for item management — already implemented (publish/unpublish/delete in project Content tab with multi-select)
57 57 - [x] **[LOW]** Add keyboard shortcuts — `?` help overlay with shortcut list, `Esc` closes modals, `Cmd+S` saves forms (Cmd+K deferred until global search)
58 - - [ ] **[LOW]** Add soft delete with 7-day recovery — items/projects currently hard-delete on confirmation. Add "Recently Deleted" archive with restore option
59 - - [ ] **[LOW]** Add wishlist/bookmark for fans — simple heart icon on item cards, DB table for saved items. Table-stakes vs Bandcamp/Gumroad/itch.io
58 + - [x] **[LOW]** Add soft delete with 7-day recovery — items now soft-deleted (deleted_at column), auto-purged after 7 days by scheduler, restore endpoint at POST /api/items/{id}/restore
59 + - [x] **[LOW]** Add wishlist/bookmark for fans — "Wishlist" toggle on item pages, wishlists table, toggle API at POST /api/wishlists/{item_id}
60 60 - [x] **[LOW]** Add changelog or "What's New" — `/changelog` page with entry history, linked from site footer
61 61
62 62 ### Deferred (post-beta table stakes)
@@ -836,6 +836,7 @@
836 836 removed_by_admin: false,
837 837 removal_reason: None,
838 838 removed_at: None,
839 + deleted_at: None,
839 840 }
840 841 }
841 842
@@ -192,7 +192,7 @@
192 192 JOIN users u ON p.user_id = u.id
193 193 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
194 194 LEFT JOIN tags pt ON pt.id = pit.tag_id
195 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
195 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
196 196 "#,
197 197 )
198 198 } else if has_search {
@@ -218,7 +218,7 @@
218 218 JOIN users u ON p.user_id = u.id
219 219 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
220 220 LEFT JOIN tags pt ON pt.id = pit.tag_id
221 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
221 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
222 222 "#,
223 223 )
224 224 } else {
@@ -243,7 +243,7 @@
243 243 JOIN users u ON p.user_id = u.id
244 244 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
245 245 LEFT JOIN tags pt ON pt.id = pit.tag_id
246 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
246 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
247 247 "#,
248 248 )
249 249 };
@@ -144,7 +144,7 @@
144 144 #[tracing::instrument(skip_all)]
145 145 pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
146 146 let items = sqlx::query_as::<_, DbItem>(
147 - "SELECT * FROM items WHERE project_id = $1 ORDER BY sort_order, created_at DESC LIMIT 500",
147 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500",
148 148 )
149 149 .bind(project_id)
150 150 .fetch_all(pool)
@@ -305,11 +305,15 @@
305 305 Ok(items)
306 306 }
307 307
308 - /// Permanently delete an item by ID.
308 + /// Soft-delete an item (sets deleted_at, recoverable for 7 days).
309 309 #[tracing::instrument(skip_all)]
310 310 pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> {
311 311 sqlx::query(
312 - "DELETE FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
312 + r#"
313 + UPDATE items SET deleted_at = NOW(), is_public = false
314 + WHERE id = $1 AND deleted_at IS NULL
315 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
316 + "#,
313 317 )
314 318 .bind(id)
315 319 .bind(user_id)
@@ -319,6 +323,52 @@
319 323 Ok(())
320 324 }
321 325
326 + /// Restore a soft-deleted item.
327 + #[tracing::instrument(skip_all)]
328 + pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> {
329 + let result = sqlx::query(
330 + r#"
331 + UPDATE items SET deleted_at = NULL
332 + WHERE id = $1 AND deleted_at IS NOT NULL
333 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
334 + "#,
335 + )
336 + .bind(id)
337 + .bind(user_id)
338 + .execute(pool)
339 + .await?;
340 +
341 + Ok(result.rows_affected() > 0)
342 + }
343 +
344 + /// Get soft-deleted items for a project (for the "Recently Deleted" section).
345 + #[tracing::instrument(skip_all)]
346 + pub async fn get_deleted_items_by_project(
347 + pool: &PgPool,
348 + project_id: ProjectId,
349 + ) -> Result<Vec<DbItem>> {
350 + let items = sqlx::query_as::<_, DbItem>(
351 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC",
352 + )
353 + .bind(project_id)
354 + .fetch_all(pool)
355 + .await?;
356 +
357 + Ok(items)
358 + }
359 +
360 + /// Permanently delete items that were soft-deleted more than 7 days ago.
361 + #[tracing::instrument(skip_all)]
362 + pub async fn purge_expired_deleted_items(pool: &PgPool) -> Result<u64> {
363 + let result = sqlx::query(
364 + "DELETE FROM items WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'",
365 + )
366 + .execute(pool)
367 + .await?;
368 +
369 + Ok(result.rows_affected())
370 + }
371 +
322 372 /// Update the audio S3 key for an item
323 373 #[tracing::instrument(skip_all)]
324 374 pub async fn update_item_audio_s3_key(
@@ -63,6 +63,7 @@
63 63 pub(crate) mod webhook_events;
64 64 pub(crate) mod scheduler_jobs;
65 65 pub(crate) mod moderation;
66 + pub(crate) mod wishlists;
66 67
67 68 pub use id_types::*;
68 69 pub use validated_types::*;
@@ -185,6 +185,22 @@
185 185 }
186 186 }
187 187
188 + /// Permanently delete items that were soft-deleted more than 7 days ago.
189 + pub(super) async fn purge_expired_deleted_items(state: &AppState) {
190 + match db::items::purge_expired_deleted_items(&state.db).await {
191 + Ok(0) => {
192 + let _ = db::scheduler_jobs::record_job_run(&state.db, "soft_delete_purge", 0).await;
193 + }
194 + Ok(n) => {
195 + tracing::info!(deleted = n, "purged expired soft-deleted items");
196 + let _ = db::scheduler_jobs::record_job_run(&state.db, "soft_delete_purge", n as i64).await;
197 + }
198 + Err(e) => {
199 + tracing::error!(error = ?e, "failed to purge expired soft-deleted items");
200 + }
201 + }
202 + }
203 +
188 204 #[cfg(test)]
189 205 mod tests {
190 206 use super::*;
@@ -166,6 +166,9 @@
166 166
167 167 // Delete self-deleted creator accounts whose 90-day content grace period has expired
168 168 cleanup::delete_expired_content_removal_accounts(&state).await;
169 +
170 + // Permanently delete soft-deleted items older than 7 days
171 + cleanup::purge_expired_deleted_items(&state).await;
169 172 }
170 173 }
171 174 })