Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ If/when multi-tenancy is on the roadmap, both #54 and #16 should be re-opened to

- **Error handling:** `AppError` enum with `IntoResponse`. Conflict = 409, Unauthorized = 303 redirect with HX-Redirect.
- **Logging:** `tracing` macros only — no `println!`.
- **i18n:** `rust_i18n::t!("key")` for ALL user-facing text. Keys in `locales/en.yml` + `locales/fr.yml`. JS strings: read `<html lang>` and use embedded string map. **CRITICAL YAML FORMAT: locale files must NOT have `en:` or `fr:` as top-level wrapper — the filename determines the locale. Keys start at root level (e.g., `nav:` not `en: nav:`). After adding/changing keys, run `touch src/lib.rs` before `cargo build` to force proc macro recompilation.**
- **i18n:** `rust_i18n::t!("key")` for ALL user-facing text. Keys in **all four** locale files — `locales/{en,fr,de,it}.yml`. `tests/locale_parity.rs` fails the build on any key present in one file and missing from another, so a key added to en/fr alone turns the DB-integration job red; run `cargo test --test locale_parity` after touching a locale. JS strings: read `<html lang>` and use embedded string map. **CRITICAL YAML FORMAT: locale files must NOT have `en:` or `fr:` as top-level wrapper — the filename determines the locale. Keys start at root level (e.g., `nav:` not `en: nav:`). After adding/changing keys, run `touch src/lib.rs` before `cargo build` to force proc macro recompilation.**
- **DB queries:** MUST include `deleted_at IS NULL` in every SELECT/JOIN on entity tables. Every entity table has `deleted_at`, `version`, `created_at`, `updated_at` columns. **MariaDB type gotchas:** (1) `JSON` columns are stored as `BLOB` — use `CAST(col AS CHAR)` to read as String. (2) `BIGINT UNSIGNED NULL` columns — use `CAST(col AS SIGNED)` and read as `Option<i64>`, then convert to `u64`. (3) Never use `CAST(... AS UNSIGNED)` in SELECT — SQLx can't decode `BIGINT UNSIGNED` into Rust integers reliably. (4) `TIMESTAMP` columns in dynamic queries (`sqlx::query()`) — use `CAST(col AS DATETIME) AS col` to read as `NaiveDateTime`. Without CAST, SQLx returns a type mismatch error. This does NOT affect typed macros (`sqlx::query!`) which handle conversion automatically.
- **Optimistic locking:** UPDATE with `WHERE id = ? AND version = ?`, then `check_update_result()` from `services/locking.rs`.
- **Soft delete:** `services/soft_delete.rs` with table whitelist. Never hard-delete.
Expand Down
1 change: 1 addition & 0 deletions locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ admin:
days_remaining_warning: "%{days} Tage"
days_remaining_critical: "<7 Tage"
restore_success: "Wiederhergestellt: %{name}"
restore_error_not_found: "Dieser Eintrag liegt nicht mehr im Papierkorb. Er wurde vermutlich schon wiederhergestellt oder endgültig gelöscht."
delete_permanent_success: "Endgültig gelöscht: %{name}"
delete_permanent_modal_title: "Endgültig löschen?"
delete_permanent_modal_warning: "Dies kann nicht rückgängig gemacht werden."
Expand Down
1 change: 1 addition & 0 deletions locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ admin:
days_remaining_warning: "%{days} day"
days_remaining_critical: "<7 days"
restore_success: "Restored: %{name}"
restore_error_not_found: "That item is no longer in the trash. It may have been restored or purged already."
delete_permanent_success: "Deleted permanently: %{name}"
delete_permanent_modal_title: "Delete permanently?"
delete_permanent_modal_warning: "This cannot be undone."
Expand Down
1 change: 1 addition & 0 deletions locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ admin:
days_remaining_warning: "%{days} jour"
days_remaining_critical: "<7 jours"
restore_success: "Restauré : %{name}"
restore_error_not_found: "Cet élément n'est plus dans la corbeille. Il a peut-être déjà été restauré ou purgé."
delete_permanent_success: "Supprimé définitivement : %{name}"
delete_permanent_modal_title: "Supprimer définitivement ?"
delete_permanent_modal_warning: "Ceci ne peut pas être annulé."
Expand Down
1 change: 1 addition & 0 deletions locales/it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,7 @@ admin:
days_remaining_warning: "%{days} giorno"
days_remaining_critical: "<7 giorni"
restore_success: "Ripristinato: %{name}"
restore_error_not_found: "Questo elemento non è più nel cestino. Probabilmente è già stato ripristinato o eliminato definitivamente."
delete_permanent_success: "Eliminato definitivamente: %{name}"
delete_permanent_modal_title: "Eliminare definitivamente?"
delete_permanent_modal_warning: "Questa azione non può essere annullata."
Expand Down
245 changes: 245 additions & 0 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,21 @@ struct AdminTrashPermanentDeleteModal {
inner_form_html: String,
}

#[derive(Template)]
#[template(path = "fragments/admin_trash_restore_modal.html")]
struct AdminTrashRestoreModal {
/// Issue #478 — thin wrapper around the same UX-DR8 macro as the
/// permanent-delete modal. Shown only on the conflict path; a
/// conflict-free restore never opens a dialog.
title: String,
body_html: String,
confirm_label: String,
cancel_label: String,
action_url: String,
csrf_token: String,
version: i32,
}

#[derive(Template)]
#[template(path = "fragments/admin_trash_panel.html")]
struct AdminTrashPanel {
Expand Down Expand Up @@ -554,6 +569,24 @@ pub struct PermanentDeleteConfirmQuery {
pub page: Option<u32>,
}

/// Query for `POST /admin/trash/{table}/{id}/restore` (issue #478).
/// `version` drives the optimistic lock; `clear_conflicts` marks the
/// second pass, after the admin confirmed the conflict modal; the rest
/// are the panel filters threaded through so the post-restore re-render
/// lands on the same view (same contract as `PermanentDeleteConfirmQuery`).
#[derive(Debug, Deserialize)]
pub struct RestoreQuery {
pub version: Option<i32>,
/// Typed as a string, not a `bool`: `serde_urlencoded` accepts only
/// `true` / `false` for a bool, so a hand-edited `?clear_conflicts=1`
/// would 400 instead of doing the obvious thing. Parsed through the
/// strict accept-set the project uses for its flags elsewhere.
pub clear_conflicts: Option<String>,
pub entity_type: Option<String>,
pub search: Option<String>,
pub page: Option<u32>,
}

pub async fn admin_trash_permanent_delete_confirm(
State(state): State<AppState>,
session: Session,
Expand Down Expand Up @@ -808,6 +841,218 @@ pub(crate) async fn render_admin_for_reference_data(
render_admin(state, session, loc, uri, is_htmx, AdminTab::ReferenceData, None).await
}

/// Restore a soft-deleted item from the Trash (issue #478).
///
/// The Restore button had been rendering a URL to a route that was never
/// registered, so a click produced a 404 that HTMX does not swap — no
/// restore, no error, nothing. Everything below it already existed:
/// `TrashService::restore`, `detect_restore_conflicts`,
/// `restore_with_conflicts_cleared`, and the `admin.trash.restore_*`
/// locale keys. This handler is the missing wiring.
///
/// **POST, not GET.** The button used to emit `hx-get`. A state-changing
/// GET sits outside the CSRF middleware (story 8-2 guards
/// POST/PUT/PATCH/DELETE only) and is fair game for any prefetcher, so
/// the route is registered as POST and the button posts.
///
/// Two passes when relationships changed while the item sat in the trash:
/// the first returns the conflict modal (retargeted into `#modal-slot`),
/// its Confirm re-posts with `clear_conflicts=1`. A conflict-free restore
/// takes the single-click path.
pub async fn admin_trash_restore(
State(state): State<AppState>,
session: Session,
Extension(locale): Extension<Locale>,
axum::extract::Path((table, id)): axum::extract::Path<(String, u64)>,
Query(params): Query<RestoreQuery>,
) -> Result<Response, AppError> {
session.require_role_with_return(Role::Admin, "/admin?tab=trash", locale.0)?;
let loc = locale.0;

let version = params
.version
.ok_or(AppError::BadRequest("Missing or invalid version".to_string()))?;

// `get_trash_entry` validates `table` against ALLOWED_TABLES and
// filters on `deleted_at IS NOT NULL`, so a purged (or already
// restored) row lands here rather than in the service layer.
let entry = crate::models::trash::TrashModel::get_trash_entry(&state.pool, &table, id)
.await?
.ok_or_else(|| {
AppError::NotFound(
rust_i18n::t!("admin.trash.restore_error_not_found", locale = loc).to_string(),
)
})?;

let clear_conflicts = matches!(
params.clear_conflicts.as_deref(),
Some("1" | "true" | "TRUE")
);
let conflicts =
crate::services::trash::TrashService::detect_restore_conflicts(&state.pool, &table, id)
.await?;

if !conflicts.is_empty() && !clear_conflicts {
// The version in the URL is the one the admin's page carried, not
// a fresh read: a stale panel must still lose the optimistic lock
// when the modal's Confirm comes back.
let action_url = format!(
"/admin/trash/{}/{}/restore?version={}&clear_conflicts=1&entity_type={}&search={}&page={}",
crate::utils::html_escape(&table),
id,
version,
crate::utils::url_encode(params.entity_type.as_deref().unwrap_or("")),
crate::utils::url_encode(params.search.as_deref().unwrap_or("")),
params.page.unwrap_or(1).max(1),
);
let modal_html = render_restore_conflict_modal(
&session,
loc,
&action_url,
version,
&entry.item_name,
&conflicts,
)?;

// The click came from the panel's Restore button, whose
// `hx-target` is `#admin-trash-panel`. Retarget so the dialog
// lands in the stable `#modal-slot` (polish-1: outside any
// HTMX-swappable region) instead of replacing the panel with a
// modal. Not stripped by `ModalConfirmRetargetGuard` — that
// layer only touches responses to requests carrying
// `X-Modal-Confirm`, which a panel button never sends.
let mut response = Html(modal_html).into_response();
response.headers_mut().insert(
axum::http::HeaderName::from_static("hx-retarget"),
axum::http::HeaderValue::from_static("#modal-slot"),
);
response.headers_mut().insert(
axum::http::HeaderName::from_static("hx-reswap"),
axum::http::HeaderValue::from_static("innerHTML"),
);
return Ok(response);
}

let restored = if clear_conflicts {
crate::services::trash::TrashService::restore_with_conflicts_cleared(
&state.pool,
&table,
id,
version,
)
.await?
} else {
crate::services::trash::TrashService::restore(&state.pool, &table, id, version).await?
};

// Forensics, mirroring `permanent_delete_from_trash` — restoring a
// row is the inverse of purging it and belongs in the same trail.
// Best-effort on purpose: the restore already committed, so a failed
// audit INSERT must not turn a successful action into an error page.
if let Some(user_id) = session.user_id {
let actor_username: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| format!("user-{}", user_id));

if let Err(e) = crate::models::admin_audit::AdminAuditModel::create(
&state.pool,
user_id,
"restore_from_trash",
Some(&table),
Some(id),
Some(serde_json::json!({
"user_username": actor_username,
"user_role": session.role.to_string(),
"item_name": restored.item_name,
"conflicts_cleared": clear_conflicts,
})),
)
.await
{
tracing::warn!(error = %e, table = %table, id = id, "restore audit entry failed");
}
}

let success_msg =
rust_i18n::t!("admin.trash.restore_success", locale = loc, name = &restored.item_name)
.to_string();
let feedback = feedback_html("success", &success_msg, "");

// Re-render with the filters the admin was looking at, exactly as the
// permanent-delete handler does (patch P12).
let filters = TrashQuery {
entity_type: params.entity_type,
search: params.search,
page: params.page,
};
let panel_html = render_trash_panel(&state, loc, &filters).await?;

// `HX-Trigger: modal-close` closes the conflict modal on the second
// pass. Harmless on the single-click path: modal.js only closes when
// the finished Confirm came from its own slot.
Ok(HtmxResponse {
main: panel_html,
oob: vec![OobUpdate {
swap_mode: Default::default(),
target: "feedback-list".to_string(),
content: feedback,
}],
}
.into_response_with_hx_trigger("modal-close"))
}

/// Build the conflict modal for [`admin_trash_restore`]. Kept separate so
/// the handler reads as one flow. Every interpolated value carrying user
/// data goes through `html_escape` — the macro consumes `body_html` with
/// `|safe` (CSP-clean, no inline script/style).
fn render_restore_conflict_modal(
session: &Session,
loc: &'static str,
action_url: &str,
version: i32,
item_name: &str,
conflicts: &[crate::services::trash::ConflictInfo],
) -> Result<String, AppError> {
let explanation = rust_i18n::t!("admin.trash.restore_modal_explanation", locale = loc).to_string();
let conflicts_label = rust_i18n::t!("admin.trash.restore_modal_conflicts", locale = loc).to_string();

let items = conflicts
.iter()
.map(|c| format!("<li>{}</li>", crate::utils::html_escape(&c.description)))
.collect::<Vec<_>>()
.join("");

let body_html = format!(
r##"<p class="mb-3">{explanation}</p>
<p class="font-mono font-bold text-stone-900 dark:text-white break-all mb-3">{item_name}</p>
<p class="text-sm font-semibold mb-1">{conflicts_label}</p>
<ul class="list-disc list-inside text-sm space-y-1">{items}</ul>"##,
explanation = crate::utils::html_escape(&explanation),
item_name = crate::utils::html_escape(item_name),
conflicts_label = crate::utils::html_escape(&conflicts_label),
items = items,
);

let modal = AdminTrashRestoreModal {
title: rust_i18n::t!("admin.trash.restore_modal_title", locale = loc).to_string(),
body_html,
confirm_label: rust_i18n::t!("admin.trash.restore_modal_clear_conflicts", locale = loc)
.to_string(),
cancel_label: rust_i18n::t!("admin.trash.restore_modal_cancel", locale = loc).to_string(),
action_url: action_url.to_string(),
csrf_token: session.csrf_token.clone(),
version,
};

modal
.render()
.map_err(|_| AppError::Internal("Modal render failed".to_string()))
}

pub async fn admin_trash_panel(
State(state): State<AppState>,
session: Session,
Expand Down
5 changes: 5 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,11 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/admin/trash", axum::routing::get(admin::admin_trash_panel))
.route("/admin/trash/{table}/{id}/permanent-delete", axum::routing::get(admin::admin_trash_permanent_delete_confirm).post(admin::admin_trash_permanent_delete))
// Issue #478: the panel's Restore button rendered this URL from
// story 8-6 on, but the route was never registered — every click
// 404'd, and HTMX does not swap a 4xx, so nothing happened at all.
// POST rather than GET so the CSRF layer (story 8-2) covers it.
.route("/admin/trash/{table}/{id}/restore", axum::routing::post(admin::admin_trash_restore))
// Admin → System settings (story 8-5). 4 routes — 1 GET panel + 3
// POST per-form saves. All Admin-gated, all CSRF-protected via the
// 8-2 middleware (none added to CSRF_EXEMPT_ROUTES).
Expand Down
8 changes: 7 additions & 1 deletion templates/fragments/admin_trash_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ <h2 id="admin-trash-heading" class="sr-only">{{ heading }}</h2>
<td class="px-4 py-3 text-xs text-stone-600 dark:text-stone-400">{{ entry.deleted_at.format("%Y-%m-%d %H:%M") }}</td>
<td class="px-4 py-3 text-center font-semibold {% if entry.days_remaining <= 7 %}text-red-600 dark:text-red-400{% endif %}">{{ entry.days_remaining }}</td>
<td class="px-4 py-3 text-right space-x-2">
<button hx-get="/admin/trash/{{ entry.table_name }}/{{ entry.id }}/restore?version={{ entry.version }}" hx-target="#admin-trash-panel" hx-swap="outerHTML" class="px-2 py-1 text-xs bg-green-700 hover:bg-green-800 text-white rounded-md">{{ btn_restore }}</button>
{# Issue #478: POST (the route mutates, so it rides the CSRF layer),
and the current filters / page are threaded through so the
post-restore re-render lands the admin back on the same view —
same contract as the permanent-delete button below.
`data-modal-trigger` is a focus anchor for the conflict modal
this may open; a conflict-free restore opens no dialog. #}
<button data-modal-trigger hx-post="/admin/trash/{{ entry.table_name }}/{{ entry.id }}/restore?version={{ entry.version }}&entity_type={{ entity_type_filter|urlencode }}&search={{ search_query|urlencode }}&page={{ current_page }}" hx-target="#admin-trash-panel" hx-swap="outerHTML" class="px-2 py-1 text-xs bg-green-700 hover:bg-green-800 text-white rounded-md">{{ btn_restore }}</button>
{# R3-N1: thread current filters / page through the confirm-modal URL so
the post-delete re-render lands the admin back on the same view. #}
<button data-modal-trigger hx-get="/admin/trash/{{ entry.table_name }}/{{ entry.id }}/permanent-delete?version={{ entry.version }}&entity_type={{ entity_type_filter|urlencode }}&search={{ search_query|urlencode }}&page={{ current_page }}" hx-target="#modal-slot" hx-swap="innerHTML" class="px-2 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded-md">{{ btn_delete_permanently }}</button>
Expand Down
26 changes: 26 additions & 0 deletions templates/fragments/admin_trash_restore_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{# Issue #478 — restore-conflict modal. Shown only when
`TrashService::detect_restore_conflicts` reports that relationships
changed while the item sat in the trash (series reassigned, contributor
role removed). The Confirm button re-posts the same restore URL with
`clear_conflicts=1`, which routes to
`TrashService::restore_with_conflicts_cleared`.

Variant "warning" (indigo), not "delete" — restoring is not destructive;
what the Confirm clears is the stale relationship rows, and the body
lists them so the admin knows what they are agreeing to. Cancel keeps
the item deleted, per the `restore_modal_cancel` copy. #}
{% import "components/modal.html" as modal %}
{% call modal::modal(
"warning",
title,
body_html,
confirm_label,
cancel_label,
action_url,
"POST",
csrf_token,
"#admin-trash-panel",
"outerHTML",
version,
"",
) %}{% endcall %}
Loading