style(dashboard): move version footer out of card
This commit is contained in:
@@ -4,6 +4,7 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
||||
use crate::service::util::user_scope_condition;
|
||||
|
||||
@@ -21,6 +22,17 @@ pub struct DeleteResult {
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct TrashEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub deleted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub struct DeleteParams<'a> {
|
||||
/// If set, delete a single entry by name.
|
||||
pub name: Option<&'a str>,
|
||||
@@ -36,12 +48,156 @@ pub struct DeleteParams<'a> {
|
||||
/// Prevents accidental mass deletion when filters are too broad.
|
||||
pub const MAX_BULK_DELETE: usize = 1000;
|
||||
|
||||
pub async fn list_deleted_entries(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<TrashEntry>> {
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, folder, type, deleted_at FROM entries \
|
||||
WHERE user_id = $1 AND deleted_at IS NOT NULL \
|
||||
ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
};
|
||||
|
||||
let conflict_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&row.folder)
|
||||
.bind(&row.name)
|
||||
.bind(row.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if conflict_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::ConflictEntryName {
|
||||
folder: row.folder,
|
||||
name: row.name,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"restore",
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
json!({ "entry_id": row.id }),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
};
|
||||
|
||||
purge_entry_record(&mut tx, row.id).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"purge",
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
json!({ "entry_id": row.id }),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result<u64> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExpiredRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows: Vec<ExpiredRow> = sqlx::query_as(
|
||||
"SELECT id FROM entries \
|
||||
WHERE deleted_at IS NOT NULL \
|
||||
AND deleted_at < NOW() - INTERVAL '3 months' \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for row in &rows {
|
||||
purge_entry_record(&mut tx, row.id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(rows.len() as u64)
|
||||
}
|
||||
|
||||
/// Delete a single entry by id (multi-tenant: `user_id` must match).
|
||||
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
@@ -61,7 +217,7 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
|
||||
let name = row.name.clone();
|
||||
let entry_row: EntryRow = (&row).into();
|
||||
|
||||
snapshot_and_delete(
|
||||
snapshot_and_soft_delete(
|
||||
&mut tx,
|
||||
&folder,
|
||||
&entry_type,
|
||||
@@ -141,7 +297,7 @@ async fn delete_one(
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT folder, type FROM entries WHERE {}",
|
||||
"SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
|
||||
@@ -198,7 +354,8 @@ async fn delete_one(
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE {} AND deleted_at IS NULL FOR UPDATE",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||
@@ -238,7 +395,7 @@ async fn delete_one(
|
||||
|
||||
let folder = row.folder.clone();
|
||||
let entry_type = row.entry_type.clone();
|
||||
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||
snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
user_id,
|
||||
@@ -305,7 +462,7 @@ async fn delete_bulk(
|
||||
if dry_run {
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} ORDER BY type, name"
|
||||
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
@@ -337,7 +494,7 @@ async fn delete_bulk(
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} ORDER BY type, name FOR UPDATE"
|
||||
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
@@ -371,8 +528,9 @@ async fn delete_bulk(
|
||||
tags: row.tags.clone(),
|
||||
metadata: row.metadata.clone(),
|
||||
notes: row.notes.clone(),
|
||||
name: row.name.clone(),
|
||||
};
|
||||
snapshot_and_delete(
|
||||
snapshot_and_soft_delete(
|
||||
&mut tx,
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
@@ -406,7 +564,7 @@ async fn delete_bulk(
|
||||
})
|
||||
}
|
||||
|
||||
async fn snapshot_and_delete(
|
||||
async fn snapshot_and_soft_delete(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
folder: &str,
|
||||
entry_type: &str,
|
||||
@@ -468,11 +626,33 @@ async fn snapshot_and_delete(
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||
sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn purge_entry_record(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let fields: Vec<SecretFieldRow> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
|
||||
if !secret_ids.is_empty() {
|
||||
sqlx::query(
|
||||
|
||||
Reference in New Issue
Block a user