max / makenotwork
17 files changed,
+273 insertions,
-30 deletions
| @@ -55,8 +55,8 @@ Usability audit grade: B. Complexity C+, Completeness B+, Learnability B, Discov | |||
| 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) |
| @@ -0,0 +1,12 @@ | |||
| 1 | + | -- Soft delete: items get a grace period before permanent deletion | |
| 2 | + | ALTER TABLE items ADD COLUMN deleted_at TIMESTAMPTZ; | |
| 3 | + | ||
| 4 | + | -- Wishlists: fans can bookmark items they want to buy later | |
| 5 | + | CREATE TABLE IF NOT EXISTS wishlists ( | |
| 6 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 7 | + | item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, | |
| 8 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 9 | + | PRIMARY KEY (user_id, item_id) | |
| 10 | + | ); | |
| 11 | + | ||
| 12 | + | CREATE INDEX IF NOT EXISTS idx_wishlists_item ON wishlists(item_id); |
| @@ -192,7 +192,7 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result | |||
| 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 @@ pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> { | |||
| 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 @@ pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<( | |||
| 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 @@ pub(crate) mod pending_refunds; | |||
| 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::*; |
| @@ -110,6 +110,8 @@ pub struct DbItem { | |||
| 110 | 110 | pub removal_reason: Option<String>, | |
| 111 | 111 | /// When the admin removed this item. | |
| 112 | 112 | pub removed_at: Option<DateTime<Utc>>, | |
| 113 | + | /// When the creator soft-deleted this item (NULL = not deleted, purged after 7 days). | |
| 114 | + | pub deleted_at: Option<DateTime<Utc>>, | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 115 | 117 | /// Content-type-specific data extracted from a `DbItem`. | |
| @@ -321,6 +323,7 @@ mod tests { | |||
| 321 | 323 | removed_by_admin: false, | |
| 322 | 324 | removal_reason: None, | |
| 323 | 325 | removed_at: None, | |
| 326 | + | deleted_at: None, | |
| 324 | 327 | } | |
| 325 | 328 | } | |
| 326 | 329 |
| @@ -0,0 +1,83 @@ | |||
| 1 | + | //! Wishlist/bookmark queries: fans save items they want to buy later. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use sqlx::PgPool; | |
| 5 | + | ||
| 6 | + | use super::{ItemId, UserId}; | |
| 7 | + | use crate::error::Result; | |
| 8 | + | ||
| 9 | + | /// Check if an item is in the user's wishlist. | |
| 10 | + | #[tracing::instrument(skip_all)] | |
| 11 | + | pub async fn is_wishlisted(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<bool> { | |
| 12 | + | let exists: bool = sqlx::query_scalar( | |
| 13 | + | "SELECT EXISTS(SELECT 1 FROM wishlists WHERE user_id = $1 AND item_id = $2)", | |
| 14 | + | ) | |
| 15 | + | .bind(user_id) | |
| 16 | + | .bind(item_id) | |
| 17 | + | .fetch_one(pool) | |
| 18 | + | .await?; | |
| 19 | + | ||
| 20 | + | Ok(exists) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Add an item to the user's wishlist (idempotent). | |
| 24 | + | #[tracing::instrument(skip_all)] | |
| 25 | + | pub async fn add_to_wishlist(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> { | |
| 26 | + | sqlx::query( | |
| 27 | + | "INSERT INTO wishlists (user_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 28 | + | ) | |
| 29 | + | .bind(user_id) | |
| 30 | + | .bind(item_id) | |
| 31 | + | .execute(pool) | |
| 32 | + | .await?; | |
| 33 | + | ||
| 34 | + | Ok(()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Remove an item from the user's wishlist. | |
| 38 | + | #[tracing::instrument(skip_all)] | |
| 39 | + | pub async fn remove_from_wishlist(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> { | |
| 40 | + | sqlx::query("DELETE FROM wishlists WHERE user_id = $1 AND item_id = $2") | |
| 41 | + | .bind(user_id) | |
| 42 | + | .bind(item_id) | |
| 43 | + | .execute(pool) | |
| 44 | + | .await?; | |
| 45 | + | ||
| 46 | + | Ok(()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// A wishlisted item with joined display data. | |
| 50 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 51 | + | #[allow(dead_code)] | |
| 52 | + | pub struct WishlistItem { | |
| 53 | + | pub item_id: ItemId, | |
| 54 | + | pub title: String, | |
| 55 | + | pub item_type: String, | |
| 56 | + | pub price_cents: i32, | |
| 57 | + | pub creator: String, | |
| 58 | + | pub added_at: DateTime<Utc>, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Get the user's wishlist with item details. | |
| 62 | + | #[allow(dead_code)] | |
| 63 | + | #[tracing::instrument(skip_all)] | |
| 64 | + | pub async fn get_wishlist(pool: &PgPool, user_id: UserId) -> Result<Vec<WishlistItem>> { | |
| 65 | + | let items = sqlx::query_as::<_, WishlistItem>( | |
| 66 | + | r#" | |
| 67 | + | SELECT w.item_id, i.title, i.item_type::TEXT as item_type, i.price_cents, | |
| 68 | + | u.username AS creator, w.created_at AS added_at | |
| 69 | + | FROM wishlists w | |
| 70 | + | JOIN items i ON i.id = w.item_id | |
| 71 | + | JOIN projects p ON p.id = i.project_id | |
| 72 | + | JOIN users u ON u.id = p.user_id | |
| 73 | + | WHERE w.user_id = $1 AND i.is_public = true AND i.deleted_at IS NULL | |
| 74 | + | ORDER BY w.created_at DESC | |
| 75 | + | LIMIT 200 | |
| 76 | + | "#, | |
| 77 | + | ) | |
| 78 | + | .bind(user_id) | |
| 79 | + | .fetch_all(pool) | |
| 80 | + | .await?; | |
| 81 | + | ||
| 82 | + | Ok(items) | |
| 83 | + | } |
| @@ -836,6 +836,7 @@ mod tests { | |||
| 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 |
| @@ -272,7 +272,7 @@ pub(in crate::routes::api) async fn update_item( | |||
| 272 | 272 | })) | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | - | /// Delete an item owned by the authenticated user. | |
| 275 | + | /// Soft-delete an item owned by the authenticated user (recoverable for 7 days). | |
| 276 | 276 | #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))] | |
| 277 | 277 | pub(in crate::routes::api) async fn delete_item( | |
| 278 | 278 | State(state): State<AppState>, | |
| @@ -282,32 +282,32 @@ pub(in crate::routes::api) async fn delete_item( | |||
| 282 | 282 | ) -> Result<Response> { | |
| 283 | 283 | tracing::Span::current().record("item_id", tracing::field::display(&id)); | |
| 284 | 284 | user.check_not_suspended()?; | |
| 285 | - | let (item, project) = verify_item_ownership(&state, id, user.id).await?; | |
| 286 | - | ||
| 287 | - | // Calculate total file bytes to decrement from storage counter | |
| 288 | - | let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?; | |
| 289 | - | let version_bytes = db::versions::sum_file_sizes_for_item(&state.db, id).await?; | |
| 290 | - | let item_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0) | |
| 291 | - | + file_sizes.cover_file_size_bytes.unwrap_or(0) | |
| 292 | - | + file_sizes.video_file_size_bytes.unwrap_or(0); | |
| 293 | - | let total_bytes = item_bytes + version_bytes; | |
| 285 | + | let (item, _project) = verify_item_ownership(&state, id, user.id).await?; | |
| 294 | 286 | ||
| 295 | 287 | db::items::delete_item(&state.db, id, user.id).await?; | |
| 296 | 288 | db::projects::bump_cache_generation(&state.db, item.project_id).await?; | |
| 297 | 289 | ||
| 298 | - | // Decrement storage counter | |
| 299 | - | if total_bytes > 0 { | |
| 300 | - | db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?; | |
| 290 | + | // Storage is reclaimed when the scheduler purges after 7 days | |
| 291 | + | ||
| 292 | + | Ok(crate::helpers::htmx_toast_response("Item moved to Recently Deleted. You can restore it within 7 days.", "success").into_response()) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /// Restore a soft-deleted item. | |
| 296 | + | #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))] | |
| 297 | + | pub(in crate::routes::api) async fn restore_item( | |
| 298 | + | State(state): State<AppState>, | |
| 299 | + | AuthUser(user): AuthUser, | |
| 300 | + | Path(id): Path<ItemId>, | |
| 301 | + | ) -> Result<impl IntoResponse> { | |
| 302 | + | user.check_not_suspended()?; | |
| 303 | + | verify_item_ownership(&state, id, user.id).await?; | |
| 304 | + | ||
| 305 | + | let restored = db::items::restore_item(&state.db, id, user.id).await?; | |
| 306 | + | if !restored { | |
| 307 | + | return Err(AppError::NotFound); | |
| 301 | 308 | } | |
| 302 | 309 | ||
| 303 | - | let mut response = crate::helpers::htmx_toast_response("Item deleted", "success").into_response(); | |
| 304 | - | response.headers_mut().insert( | |
| 305 | - | "HX-Redirect", | |
| 306 | - | format!("/dashboard/project/{}", project.slug) | |
| 307 | - | .parse() | |
| 308 | - | .expect("redirect path is valid"), | |
| 309 | - | ); | |
| 310 | - | Ok(response) | |
| 310 | + | Ok(crate::helpers::htmx_toast_response("Item restored", "success")) | |
| 311 | 311 | } | |
| 312 | 312 | ||
| 313 | 313 | /// Duplicate an item and its metadata, creating a new draft. |
| @@ -12,7 +12,7 @@ mod versions; | |||
| 12 | 12 | pub(super) use bulk::{bulk_delete, bulk_publish, bulk_unpublish}; | |
| 13 | 13 | pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed}; | |
| 14 | 14 | pub(super) use chapters::{create_chapter, delete_chapter, list_chapters, update_chapter}; | |
| 15 | - | pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, update_item}; | |
| 15 | + | pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, restore_item, update_item}; | |
| 16 | 16 | pub(super) use refund::refund_transaction; | |
| 17 | 17 | pub(super) use sections::{create_section, delete_section, list_sections, reorder_sections, update_section}; | |
| 18 | 18 | pub(super) use tags::{add_tag, remove_tag, set_primary_tag}; |
| @@ -36,6 +36,7 @@ pub(crate) mod ssh_keys; | |||
| 36 | 36 | mod reports; | |
| 37 | 37 | mod collections; | |
| 38 | 38 | mod validate; | |
| 39 | + | mod wishlists; | |
| 39 | 40 | mod domains; | |
| 40 | 41 | mod guest_checkout; | |
| 41 | 42 | mod imports; | |
| @@ -235,6 +236,7 @@ pub fn api_routes() -> Router<AppState> { | |||
| 235 | 236 | .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed)) | |
| 236 | 237 | // Refund | |
| 237 | 238 | .route("/api/items/{id}/refund", post(items::refund_transaction)) | |
| 239 | + | .route("/api/items/{id}/restore", post(items::restore_item)) | |
| 238 | 240 | // Tag routes (HTMX) | |
| 239 | 241 | .route("/api/items/{id}/tags", post(items::add_tag)) | |
| 240 | 242 | .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag)) | |
| @@ -330,6 +332,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 330 | 332 | .route("/api/collections/{id}/items/{item_id}", post(collections::add_item)) | |
| 331 | 333 | .route("/api/collections/{id}/items/{item_id}", delete(collections::remove_item)) | |
| 332 | 334 | .route("/api/collections/{id}/items/reorder", put(collections::reorder_items)) | |
| 335 | + | // Wishlists | |
| 336 | + | .route("/api/wishlists/{item_id}", post(wishlists::toggle_wishlist)) | |
| 333 | 337 | // Custom domains | |
| 334 | 338 | .route("/api/domains", post(domains::add_domain)) | |
| 335 | 339 | .route("/api/domains/verify", post(domains::verify_domain)) |
| @@ -0,0 +1,38 @@ | |||
| 1 | + | //! Wishlist API: toggle items in the user's wishlist. | |
| 2 | + | ||
| 3 | + | use axum::extract::{Path, State}; | |
| 4 | + | use axum::response::IntoResponse; | |
| 5 | + | use axum::Json; | |
| 6 | + | ||
| 7 | + | use crate::{ | |
| 8 | + | auth::AuthUser, | |
| 9 | + | db::{self, ItemId}, | |
| 10 | + | error::{AppError, Result}, | |
| 11 | + | AppState, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | /// Toggle an item's wishlist status. Returns the new state. | |
| 15 | + | #[tracing::instrument(skip_all, name = "wishlists::toggle")] | |
| 16 | + | pub(super) async fn toggle_wishlist( | |
| 17 | + | State(state): State<AppState>, | |
| 18 | + | AuthUser(user): AuthUser, | |
| 19 | + | Path(item_id): Path<ItemId>, | |
| 20 | + | ) -> Result<impl IntoResponse> { | |
| 21 | + | // Verify item exists and is public | |
| 22 | + | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 23 | + | .await? | |
| 24 | + | .ok_or(AppError::NotFound)?; | |
| 25 | + | if !item.is_public { | |
| 26 | + | return Err(AppError::NotFound); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | let currently_wishlisted = db::wishlists::is_wishlisted(&state.db, user.id, item_id).await?; | |
| 30 | + | ||
| 31 | + | if currently_wishlisted { | |
| 32 | + | db::wishlists::remove_from_wishlist(&state.db, user.id, item_id).await?; | |
| 33 | + | } else { | |
| 34 | + | db::wishlists::add_to_wishlist(&state.db, user.id, item_id).await?; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | Ok(Json(serde_json::json!({ "wishlisted": !currently_wishlisted }))) | |
| 38 | + | } |
| @@ -63,6 +63,9 @@ pub(crate) async fn render_item_page( | |||
| 63 | 63 | if !db_item.is_public && !is_owner { | |
| 64 | 64 | return Err(AppError::NotFound); | |
| 65 | 65 | } | |
| 66 | + | if db_item.deleted_at.is_some() && !is_owner { | |
| 67 | + | return Err(AppError::NotFound); | |
| 68 | + | } | |
| 66 | 69 | ||
| 67 | 70 | let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?; | |
| 68 | 71 | ||
| @@ -261,6 +264,12 @@ pub(crate) async fn render_item_page( | |||
| 261 | 264 | let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?; | |
| 262 | 265 | let sections: Vec<ItemSection> = db_sections.iter().map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)).collect(); | |
| 263 | 266 | ||
| 267 | + | let is_wishlisted = if let Some(ref user) = maybe_user { | |
| 268 | + | db::wishlists::is_wishlisted(&state.db, user.id, db_item.id).await.unwrap_or(false) | |
| 269 | + | } else { | |
| 270 | + | false | |
| 271 | + | }; | |
| 272 | + | ||
| 264 | 273 | Ok(ItemTemplate { | |
| 265 | 274 | csrf_token, | |
| 266 | 275 | session_user: maybe_user, | |
| @@ -277,6 +286,7 @@ pub(crate) async fn render_item_page( | |||
| 277 | 286 | containing_bundles: containing_bundle_views, | |
| 278 | 287 | sections, | |
| 279 | 288 | is_owner, | |
| 289 | + | is_wishlisted, | |
| 280 | 290 | } | |
| 281 | 291 | .into_response()) | |
| 282 | 292 | } |
| @@ -185,6 +185,22 @@ pub(super) async fn scrub_stale_ip_addresses(state: &AppState) { | |||
| 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 @@ pub fn spawn_scheduler( | |||
| 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 | }) |
| @@ -277,6 +277,8 @@ pub struct ItemTemplate { | |||
| 277 | 277 | pub sections: Vec<ItemSection>, | |
| 278 | 278 | /// Whether the current user is the item's creator (for dashboard links). | |
| 279 | 279 | pub is_owner: bool, | |
| 280 | + | /// Whether the current user has wishlisted this item. | |
| 281 | + | pub is_wishlisted: bool, | |
| 280 | 282 | } | |
| 281 | 283 | ||
| 282 | 284 | /// Blog/article reader view. |
| @@ -400,6 +400,11 @@ | |||
| 400 | 400 | <a href="/dashboard/item/{{ item.id }}">Edit</a> · | |
| 401 | 401 | <a href="/dashboard/item/{{ item.id }}#embed">Embed</a> · | |
| 402 | 402 | {% endif %} | |
| 403 | + | {% if session_user.is_some() && !is_owner %} | |
| 404 | + | <a href="javascript:void(0)" id="wishlist-btn" | |
| 405 | + | onclick="toggleWishlist('{{ item.id }}')" | |
| 406 | + | style="{% if is_wishlisted %}font-weight: bold;{% endif %}">{% if is_wishlisted %}Wishlisted{% else %}Wishlist{% endif %}</a> · | |
| 407 | + | {% endif %} | |
| 403 | 408 | <a href="javascript:void(0)" onclick="navigator.clipboard.writeText(window.location.origin + '/i/{{ item.id }}').then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy link', 1500) })">Copy link</a> · | |
| 404 | 409 | {% if session_user.is_some() %} | |
| 405 | 410 | <a href="javascript:void(0)" onclick="document.getElementById('report-modal').style.display='flex'">Report this item</a> · | |
| @@ -558,6 +563,21 @@ function downloadVersion(versionId) { | |||
| 558 | 563 | dropdownOpen = false; | |
| 559 | 564 | } | |
| 560 | 565 | }); | |
| 566 | + | ||
| 567 | + | window.toggleWishlist = function(itemId) { | |
| 568 | + | var btn = document.getElementById('wishlist-btn'); | |
| 569 | + | fetch('/api/wishlists/' + itemId, { method: 'POST', headers: csrfHeaders() }) | |
| 570 | + | .then(function(r) { return r.json(); }) | |
| 571 | + | .then(function(data) { | |
| 572 | + | if (data.wishlisted) { | |
| 573 | + | btn.textContent = 'Wishlisted'; | |
| 574 | + | btn.style.fontWeight = 'bold'; | |
| 575 | + | } else { | |
| 576 | + | btn.textContent = 'Wishlist'; | |
| 577 | + | btn.style.fontWeight = ''; | |
| 578 | + | } | |
| 579 | + | }); | |
| 580 | + | }; | |
| 561 | 581 | })(); | |
| 562 | 582 | </script> | |
| 563 | 583 | {% endblock %} |