Skip to main content

max / makenotwork

Add version delete, show filenames in version table Show uploaded filename in the Files column. Add Delete button per version (confirms, enqueues S3 cleanup, removes DB row). Delete endpoint: DELETE /api/items/{id}/versions/{version_id}. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-10 20:17 UTC
Commit: 04a7ebc04e214c739602f5a2d4b72884ca4124f2
Parent: b43f2ef
8 files changed, +71 insertions, -2 deletions
@@ -210,6 +210,16 @@ pub async fn update_version_file(
210 210 Ok(version)
211 211 }
212 212
213 + /// Delete a version by ID.
214 + #[tracing::instrument(skip_all)]
215 + pub async fn delete_version(pool: &PgPool, version_id: VersionId) -> Result<()> {
216 + sqlx::query("DELETE FROM versions WHERE id = $1")
217 + .bind(version_id)
218 + .execute(pool)
219 + .await?;
220 + Ok(())
221 + }
222 +
213 223 /// Sum all version file sizes for a given item (for storage decrement on item delete).
214 224 #[tracing::instrument(skip_all)]
215 225 pub async fn sum_file_sizes_for_item(pool: &PgPool, item_id: super::ItemId) -> Result<i64> {
@@ -17,4 +17,4 @@ 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};
19 19 pub(super) use crud::update_item_text;
20 - pub(super) use versions::{create_version, list_versions};
20 + pub(super) use versions::{create_version, delete_version, list_versions};
@@ -81,6 +81,41 @@ pub(in crate::routes::api) async fn create_version(
81 81 }))
82 82 }
83 83
84 + /// Delete a version from an owned item. Enqueues S3 deletion if file exists.
85 + #[tracing::instrument(skip_all, name = "items::delete_version")]
86 + pub(in crate::routes::api) async fn delete_version(
87 + State(state): State<AppState>,
88 + AuthUser(user): AuthUser,
89 + Path((item_id, version_id)): Path<(ItemId, VersionId)>,
90 + ) -> Result<impl IntoResponse> {
91 + user.check_not_suspended()?;
92 + verify_item_ownership(&state, item_id, user.id).await?;
93 +
94 + let version = db::versions::get_version_by_id(&state.db, version_id)
95 + .await?
96 + .ok_or(AppError::NotFound)?;
97 +
98 + if version.item_id != item_id {
99 + return Err(AppError::NotFound);
100 + }
101 +
102 + if let Some(ref s3_key) = version.s3_key {
103 + if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
104 + &state.db,
105 + &[(s3_key.clone(), "main".to_string())],
106 + "version_delete",
107 + )
108 + .await
109 + {
110 + tracing::warn!(error = ?e, "failed to enqueue S3 deletion for version");
111 + }
112 + }
113 +
114 + db::versions::delete_version(&state.db, version_id).await?;
115 +
116 + Ok(axum::http::StatusCode::OK)
117 + }
118 +
84 119 /// List all versions for a given item (public items only; drafts return 404).
85 120 #[tracing::instrument(skip_all, name = "items::list_versions")]
86 121 pub(in crate::routes::api) async fn list_versions(
@@ -249,6 +249,7 @@ pub fn api_routes() -> Router<AppState> {
249 249 .route("/api/items/{id}/text", put(items::update_item_text))
250 250 // Version routes
251 251 .route("/api/items/{id}/versions", post(items::create_version))
252 + .route("/api/items/{id}/versions/{version_id}", delete(items::delete_version))
252 253 // Custom link routes
253 254 .route("/api/links", post(links::create_link))
254 255 .route("/api/links/{id}", put(links::update_link))
@@ -446,6 +446,7 @@ impl Version {
446 446 },
447 447 is_current: v.is_current,
448 448 has_file,
449 + file_name: v.file_name.clone(),
449 450 }
450 451 }
451 452 }
@@ -731,6 +731,7 @@ pub struct Version {
731 731 pub status: String,
732 732 pub is_current: bool,
733 733 pub has_file: bool,
734 + pub file_name: Option<String>,
734 735 }
735 736
736 737 /// Row data for displaying a license key in the creator dashboard.
@@ -213,6 +213,25 @@
213 213 });
214 214 });
215 215
216 + document.querySelectorAll('.delete-version-btn').forEach(function(btn) {
217 + btn.addEventListener('click', function() {
218 + if (!confirm('Delete this version?')) return;
219 + var versionId = btn.dataset.versionId;
220 + fetch('/api/items/' + itemId + '/versions/' + versionId, {
221 + method: 'DELETE',
222 + headers: csrfHeaders()
223 + })
224 + .then(function(res) {
225 + if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
226 + throw new Error(d.error || 'Failed to delete version');
227 + });
228 + var row = btn.closest('tr');
229 + if (row) row.remove();
230 + })
231 + .catch(function(err) { showToast(err.message || 'Failed to delete version'); });
232 + });
233 + });
234 +
216 235 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
217 236 uploader.cancel();
218 237 resetVersionUpload();
@@ -25,7 +25,7 @@
25 25 <tr>
26 26 <td><span class="badge{% if version.is_current %} current{% endif %}">v{{ version.number }}</span></td>
27 27 <td>{{ version.uploaded_date }}</td>
28 - <td>{{ version.file_count }}</td>
28 + <td>{% if version.has_file %}{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}1 file{% endmatch %}{% else %}No file{% endif %}</td>
29 29 <td>{{ version.size }}</td>
30 30 <td>{{ version.downloads }}</td>
31 31 <td>{{ version.status }}</td>
@@ -37,6 +37,8 @@
37 37 <button class="secondary upload-to-version-btn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
38 38 data-version-id="{{ version.id }}">Upload File</button>
39 39 {% endif %}
40 + <button class="secondary delete-version-btn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
41 + data-version-id="{{ version.id }}">Delete</button>
40 42 </td>
41 43 </tr>
42 44 {% endfor %}