max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 | }) |