style(dashboard): move version footer out of card
This commit is contained in:
@@ -80,10 +80,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Legacy unique constraint without user_id (single-user mode)
|
||||
-- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||
ON entries(folder, name)
|
||||
WHERE user_id IS NULL;
|
||||
@@ -127,6 +129,17 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
|
||||
|
||||
-- ── entry_relations: parent-child links between entries ──────────────────
|
||||
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(parent_entry_id, child_entry_id),
|
||||
CHECK (parent_entry_id <> child_entry_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||
|
||||
-- ── audit_log: append-only operation log ─────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
@@ -170,6 +183,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
|
||||
-- Backfill: add notes to entries if not present (fresh installs already have it)
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ── secrets_history: field-level snapshot ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS secrets_history (
|
||||
@@ -404,11 +418,11 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||
ON entries(folder, name)
|
||||
WHERE user_id IS NULL;
|
||||
WHERE user_id IS NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
||||
ON entries(user_id, folder, name)
|
||||
WHERE user_id IS NOT NULL;
|
||||
WHERE user_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- ── Replace old namespace/kind indexes ────────────────────────────────────
|
||||
DROP INDEX IF EXISTS idx_entries_namespace;
|
||||
@@ -420,6 +434,8 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
||||
ON entries(folder) WHERE folder <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_type
|
||||
ON entries(type) WHERE type <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
|
||||
ON entries(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
|
||||
ON audit_log(folder, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct Entry {
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A single encrypted field belonging to an Entry.
|
||||
@@ -52,6 +53,7 @@ pub struct EntryRow {
|
||||
pub tags: Vec<String>,
|
||||
pub metadata: Value,
|
||||
pub notes: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Entry row including `name` (used for id-scoped web / service updates).
|
||||
@@ -66,6 +68,7 @@ pub struct EntryWriteRow {
|
||||
pub tags: Vec<String>,
|
||||
pub metadata: Value,
|
||||
pub notes: String,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<&EntryWriteRow> for EntryRow {
|
||||
@@ -78,6 +81,7 @@ impl From<&EntryWriteRow> for EntryRow {
|
||||
tags: r.tags.clone(),
|
||||
metadata: r.metadata.clone(),
|
||||
notes: r.notes.clone(),
|
||||
name: r.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
// Fetch existing entry by (user_id, folder, name) — the natural unique key
|
||||
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.folder)
|
||||
@@ -223,8 +223,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.name)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn build_env_map(
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, None, user_id).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub async fn export(
|
||||
params.name,
|
||||
params.tags,
|
||||
params.query,
|
||||
None,
|
||||
params.user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod export;
|
||||
pub mod get_secret;
|
||||
pub mod history;
|
||||
pub mod import;
|
||||
pub mod relations;
|
||||
pub mod rollback;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
|
||||
324
crates/secrets-core/src/service/relations.rs
Normal file
324
crates/secrets-core/src/service/relations.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RelationEntrySummary {
|
||||
pub id: Uuid,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct EntryRelations {
|
||||
pub parents: Vec<RelationEntrySummary>,
|
||||
pub children: Vec<RelationEntrySummary>,
|
||||
}
|
||||
|
||||
pub async fn add_parent_relation(
|
||||
pool: &PgPool,
|
||||
parent_entry_id: Uuid,
|
||||
child_entry_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
if parent_entry_id == child_entry_id {
|
||||
return Err(AppError::Validation {
|
||||
message: "entry cannot reference itself".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||
|
||||
let cycle_exists: bool = sqlx::query_scalar(
|
||||
"WITH RECURSIVE descendants AS ( \
|
||||
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||
UNION \
|
||||
SELECT er.child_entry_id \
|
||||
FROM entry_relations er \
|
||||
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||
) \
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||
)
|
||||
.bind(child_entry_id)
|
||||
.bind(parent_entry_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if cycle_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::Validation {
|
||||
message: "adding this relation would create a cycle".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) \
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(parent_entry_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_parent_relation(
|
||||
pool: &PgPool,
|
||||
parent_entry_id: Uuid,
|
||||
child_entry_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||
sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2")
|
||||
.bind(parent_entry_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_parent_relations(
|
||||
pool: &PgPool,
|
||||
child_entry_id: Uuid,
|
||||
parent_entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let deduped: Vec<Uuid> = parent_entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if deduped.contains(&child_entry_id) {
|
||||
return Err(AppError::Validation {
|
||||
message: "entry cannot reference itself".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut validate_ids = Vec::with_capacity(deduped.len() + 1);
|
||||
validate_ids.push(child_entry_id);
|
||||
validate_ids.extend(deduped.iter().copied());
|
||||
validate_live_entries(&mut tx, &validate_ids, user_id).await?;
|
||||
|
||||
let current_parent_ids: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1")
|
||||
.bind(child_entry_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let current: BTreeSet<Uuid> = current_parent_ids.into_iter().collect();
|
||||
let target: BTreeSet<Uuid> = deduped.iter().copied().collect();
|
||||
|
||||
for parent_id in current.difference(&target) {
|
||||
sqlx::query(
|
||||
"DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2",
|
||||
)
|
||||
.bind(*parent_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for parent_id in target.difference(¤t) {
|
||||
let cycle_exists: bool = sqlx::query_scalar(
|
||||
"WITH RECURSIVE descendants AS ( \
|
||||
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||
UNION \
|
||||
SELECT er.child_entry_id \
|
||||
FROM entry_relations er \
|
||||
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||
) \
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||
)
|
||||
.bind(child_entry_id)
|
||||
.bind(*parent_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if cycle_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::Validation {
|
||||
message: "adding this relation would create a cycle".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(*parent_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_relations_for_entries(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<Uuid, EntryRelations>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ParentRow {
|
||||
owner_entry_id: Uuid,
|
||||
id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ChildRow {
|
||||
owner_entry_id: Uuid,
|
||||
id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let (parents, children): (Vec<ParentRow>, Vec<ChildRow>) = if let Some(uid) = user_id {
|
||||
let parents = sqlx::query_as(
|
||||
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
WHERE er.child_entry_id = ANY($1) \
|
||||
AND p.user_id = $2 AND c.user_id = $2 \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.child_entry_id, p.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.bind(uid)
|
||||
.fetch_all(pool);
|
||||
let children = sqlx::query_as(
|
||||
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
WHERE er.parent_entry_id = ANY($1) \
|
||||
AND p.user_id = $2 AND c.user_id = $2 \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.parent_entry_id, c.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.bind(uid)
|
||||
.fetch_all(pool);
|
||||
(parents.await?, children.await?)
|
||||
} else {
|
||||
let parents = sqlx::query_as(
|
||||
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
WHERE er.child_entry_id = ANY($1) \
|
||||
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.child_entry_id, p.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool);
|
||||
let children = sqlx::query_as(
|
||||
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
WHERE er.parent_entry_id = ANY($1) \
|
||||
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.parent_entry_id, c.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool);
|
||||
(parents.await?, children.await?)
|
||||
};
|
||||
|
||||
let mut map: HashMap<Uuid, EntryRelations> = entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|id| (id, EntryRelations::default()))
|
||||
.collect();
|
||||
|
||||
for row in parents {
|
||||
map.entry(row.owner_entry_id)
|
||||
.or_default()
|
||||
.parents
|
||||
.push(RelationEntrySummary {
|
||||
id: row.id,
|
||||
folder: row.folder,
|
||||
entry_type: row.entry_type,
|
||||
name: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
for row in children {
|
||||
map.entry(row.owner_entry_id)
|
||||
.or_default()
|
||||
.children
|
||||
.push(RelationEntrySummary {
|
||||
id: row.id,
|
||||
folder: row.folder,
|
||||
entry_type: row.entry_type,
|
||||
name: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn validate_live_entries(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let unique_ids: Vec<Uuid> = entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let live_count: i64 = if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::bigint FROM entries \
|
||||
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&unique_ids)
|
||||
.bind(uid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::bigint FROM entries \
|
||||
WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&unique_ids)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
if live_count != unique_ids.len() as i64 {
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::EntryWriteRow;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct RollbackResult {
|
||||
@@ -17,11 +19,9 @@ pub struct RollbackResult {
|
||||
}
|
||||
|
||||
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
|
||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
entry_id: Uuid,
|
||||
to_version: Option<i64>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<RollbackResult> {
|
||||
@@ -36,88 +36,26 @@ pub async fn run(
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
// Disambiguate: find the unique entry_id for (name, folder).
|
||||
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
|
||||
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(f)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
|
||||
let live_entry: Option<EntryWriteRow> = if let Some(uid) = user_id {
|
||||
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 NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(f)
|
||||
.bind(entry_id)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
.bind(entry_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
|
||||
let live_entry = live_entry.ok_or(AppError::NotFoundEntry)?;
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
sqlx::query_as(
|
||||
@@ -142,8 +80,8 @@ pub async fn run(
|
||||
|
||||
let snap = snap.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No history found for '{}'{}.",
|
||||
name,
|
||||
"No history found for entry '{}'{}.",
|
||||
live_entry.name,
|
||||
to_version
|
||||
.map(|v| format!(" at version {}", v))
|
||||
.unwrap_or_default()
|
||||
@@ -155,21 +93,9 @@ pub async fn run(
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveEntry {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
// Lock the live entry if it exists (matched by entry_id for precision).
|
||||
let live: Option<LiveEntry> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata FROM entries \
|
||||
WHERE id = $1 FOR UPDATE",
|
||||
let live: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
@@ -192,7 +118,7 @@ pub async fn run(
|
||||
user_id,
|
||||
folder: &lr.folder,
|
||||
entry_type: &lr.entry_type,
|
||||
name,
|
||||
name: &lr.name,
|
||||
version: lr.version,
|
||||
action: "rollback",
|
||||
tags: &lr.tags,
|
||||
@@ -237,11 +163,13 @@ pub async fn run(
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \
|
||||
updated_at = NOW() WHERE id = $5",
|
||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||
version = version + 1, updated_at = NOW() WHERE id = $7",
|
||||
)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(&live_entry.name)
|
||||
.bind(&live_entry.notes)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap_metadata)
|
||||
.bind(lr.id)
|
||||
@@ -250,36 +178,7 @@ pub async fn run(
|
||||
|
||||
lr.id
|
||||
} else {
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap_metadata)
|
||||
.bind(snap.version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap_metadata)
|
||||
.bind(snap.version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
};
|
||||
|
||||
if let Some(secret_snapshot) = snap_secret_snapshot {
|
||||
@@ -292,8 +191,9 @@ pub async fn run(
|
||||
"rollback",
|
||||
&snap.folder,
|
||||
&snap.entry_type,
|
||||
name,
|
||||
&live_entry.name,
|
||||
serde_json::json!({
|
||||
"entry_id": entry_id,
|
||||
"restored_version": snap.version,
|
||||
"original_action": snap.action,
|
||||
}),
|
||||
@@ -303,7 +203,7 @@ pub async fn run(
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(RollbackResult {
|
||||
name: name.to_string(),
|
||||
name: live_entry.name,
|
||||
folder: snap.folder,
|
||||
entry_type: snap.entry_type,
|
||||
restored_version: snap.version,
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct SearchParams<'a> {
|
||||
pub name_query: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub metadata_query: Option<&'a str>,
|
||||
pub sort: &'a str,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
@@ -75,6 +76,10 @@ pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
if let Some(v) = a.metadata_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
let n = q.fetch_one(pool).await?;
|
||||
Ok(n)
|
||||
}
|
||||
@@ -90,6 +95,7 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
||||
} else {
|
||||
conditions.push("user_id IS NULL".to_string());
|
||||
}
|
||||
conditions.push("deleted_at IS NULL".to_string());
|
||||
|
||||
if a.folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
@@ -132,6 +138,14 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
||||
));
|
||||
idx += 1;
|
||||
}
|
||||
if a.metadata_query.is_some() {
|
||||
conditions.push(format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
|
||||
WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')",
|
||||
idx
|
||||
));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
@@ -164,6 +178,7 @@ pub async fn fetch_entries(
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
metadata_query: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<Entry>> {
|
||||
let params = SearchParams {
|
||||
@@ -173,6 +188,7 @@ pub async fn fetch_entries(
|
||||
name_query: None,
|
||||
tags,
|
||||
query,
|
||||
metadata_query,
|
||||
sort: "name",
|
||||
limit: FETCH_ALL_LIMIT,
|
||||
offset: 0,
|
||||
@@ -195,7 +211,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at \
|
||||
created_at, updated_at, deleted_at \
|
||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||
);
|
||||
|
||||
@@ -223,6 +239,10 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
if let Some(v) = a.metadata_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
||||
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
@@ -267,7 +287,7 @@ pub async fn resolve_entry_by_id(
|
||||
let row: Option<EntryRaw> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
|
||||
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(uid)
|
||||
@@ -276,7 +296,7 @@ pub async fn resolve_entry_by_id(
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
|
||||
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -298,7 +318,7 @@ pub async fn resolve_entry(
|
||||
folder: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<crate::models::Entry> {
|
||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
|
||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?;
|
||||
match entries.len() {
|
||||
0 => {
|
||||
if let Some(f) = folder {
|
||||
@@ -339,6 +359,7 @@ struct EntryRaw {
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl From<EntryRaw> for Entry {
|
||||
@@ -355,6 +376,7 @@ impl From<EntryRaw> for Entry {
|
||||
version: r.version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
deleted_at: r.deleted_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ pub async fn run(
|
||||
}
|
||||
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);
|
||||
@@ -464,7 +465,7 @@ pub async fn update_fields_by_id(
|
||||
|
||||
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",
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
|
||||
Reference in New Issue
Block a user