Server overhaul: one place for stored-row reads and the shared inserts
api::rows now also loads settlements, audit entries and the epoch (sync::rows is gone; audit enums parse through rows::token) and owns the expense, split, settlement and allocation inserts that sync push, recurring generation and the importer each had their own copy of (rows::Origin carries the server-owned columns). accounts::insert_account and replace_shares are shared with the importer. Recurring generation moves the per-period expense into create_period_expense. No SQL, wire or log change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -7,12 +7,12 @@ use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use kvitt_domain::{BalanceExpense, BalanceSettlement, compute_balances};
|
||||
use kvitt_server::api::{accounts::name_key, rows};
|
||||
use kvitt_server::api::accounts::{self, name_key};
|
||||
use kvitt_server::api::rows::{self, Origin};
|
||||
use kvitt_server::audit::{AuditEvent, AuditWriter};
|
||||
use kvitt_server::db as server_db;
|
||||
use kvitt_server::error::AppError;
|
||||
use kvitt_server::sync::check::{self, Fail};
|
||||
use kvitt_server::sync::rows as sync_rows;
|
||||
use kvitt_server::time::format_ts;
|
||||
use kvitt_server::wire::{
|
||||
Allocation, AuditAction, AuditSource, EntityType, PushedExpense, PushedSettlement, Split,
|
||||
@@ -66,32 +66,16 @@ async fn find_or_create_account(
|
||||
if let Some(id) = existing {
|
||||
return Ok((id, false));
|
||||
}
|
||||
let id = Uuid::now_v7().to_string();
|
||||
let id = Uuid::now_v7();
|
||||
let version = server_db::next_server_version(conn).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \
|
||||
deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.bind(&key)
|
||||
.bind(household.owner.to_string())
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for user in household.users {
|
||||
sqlx::query(
|
||||
"INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(user.id.to_string())
|
||||
.bind(DEFAULT_SHARE_BP)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok((id, true))
|
||||
accounts::insert_account(conn, id, name, household.owner, now_ts, version).await?;
|
||||
let shares: Vec<(Uuid, i64)> = household
|
||||
.users
|
||||
.iter()
|
||||
.map(|user| (user.id, DEFAULT_SHARE_BP))
|
||||
.collect();
|
||||
accounts::replace_shares(conn, id, &shares).await?;
|
||||
Ok((id.to_string(), true))
|
||||
}
|
||||
|
||||
/// The id of each non-deleted default category, by key.
|
||||
@@ -194,91 +178,20 @@ fn pushed_settlement(
|
||||
})
|
||||
}
|
||||
|
||||
async fn insert_expense(
|
||||
/// The server-owned columns of an imported row: created by the owner, no device, a new `server_version`.
|
||||
async fn origin<'a>(
|
||||
conn: &mut SqliteConnection,
|
||||
p: &PushedExpense,
|
||||
import_key: &str,
|
||||
import_key: &'a str,
|
||||
owner: Uuid,
|
||||
now_ts: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let version = server_db::next_server_version(conn).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
|
||||
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
|
||||
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
|
||||
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(p.account_id.to_string())
|
||||
.bind(p.category_id.to_string())
|
||||
.bind(p.amount_ore)
|
||||
.bind(&p.description)
|
||||
.bind(&p.notes)
|
||||
.bind(&p.expense_date)
|
||||
.bind(p.payer_user_id.to_string())
|
||||
.bind(rows::token_of(&p.split_mode)?)
|
||||
.bind(import_key)
|
||||
.bind(owner.to_string())
|
||||
.bind(owner.to_string())
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for split in &p.splits {
|
||||
sqlx::query(
|
||||
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(split.user_id.to_string())
|
||||
.bind(split.share_ore)
|
||||
.bind(split.share_bp)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_settlement(
|
||||
conn: &mut SqliteConnection,
|
||||
p: &PushedSettlement,
|
||||
import_key: &str,
|
||||
owner: Uuid,
|
||||
now_ts: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let version = server_db::next_server_version(conn).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
|
||||
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
|
||||
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(p.from_user_id.to_string())
|
||||
.bind(p.to_user_id.to_string())
|
||||
.bind(p.amount_ore)
|
||||
.bind(&p.settlement_date)
|
||||
.bind(&p.note)
|
||||
.bind(import_key)
|
||||
.bind(owner.to_string())
|
||||
.bind(owner.to_string())
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for allocation in &p.allocations {
|
||||
sqlx::query(
|
||||
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(allocation.account_id.to_string())
|
||||
.bind(allocation.amount_ore)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
now_ts: &'a str,
|
||||
) -> Result<Origin<'a>, AppError> {
|
||||
Ok(Origin {
|
||||
user_id: owner,
|
||||
device_id: None,
|
||||
import_key: Some(import_key),
|
||||
received_at: now_ts,
|
||||
server_version: server_db::next_server_version(conn).await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn audit_create(
|
||||
@@ -365,7 +278,8 @@ pub async fn write(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
insert_expense(conn, &checked, &row.import_key, owner, &now_ts).await?;
|
||||
let origin = origin(conn, &row.import_key, owner, &now_ts).await?;
|
||||
rows::insert_expense(conn, &checked, None, origin).await?;
|
||||
let stored = rows::expense(conn, checked.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::internal("imported expense vanished"))?;
|
||||
@@ -391,8 +305,9 @@ pub async fn write(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
insert_settlement(conn, &checked, &row.import_key, owner, &now_ts).await?;
|
||||
let stored = sync_rows::settlement(conn, checked.id)
|
||||
let origin = origin(conn, &row.import_key, owner, &now_ts).await?;
|
||||
rows::insert_settlement(conn, &checked, origin).await?;
|
||||
let stored = rows::settlement(conn, checked.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::internal("imported settlement vanished"))?;
|
||||
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;
|
||||
|
||||
@@ -51,7 +51,8 @@ async fn check_name_free(
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_shares(
|
||||
/// Replaces the account's `account_default_shares` rows.
|
||||
pub async fn replace_shares(
|
||||
conn: &mut SqliteConnection,
|
||||
account_id: Uuid,
|
||||
shares: &[(Uuid, i64)],
|
||||
@@ -73,6 +74,32 @@ async fn replace_shares(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a live, unarchived account row (without its default shares, see [`replace_shares`]); `name` is the
|
||||
/// trimmed name.
|
||||
pub async fn insert_account(
|
||||
conn: &mut SqliteConnection,
|
||||
id: Uuid,
|
||||
name: &str,
|
||||
created_by: Uuid,
|
||||
now_ts: &str,
|
||||
server_version: i64,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \
|
||||
deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(name)
|
||||
.bind(name_key(name))
|
||||
.bind(created_by.to_string())
|
||||
.bind(now_ts)
|
||||
.bind(now_ts)
|
||||
.bind(server_version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result<Account, AppError> {
|
||||
rows::account(conn, id)
|
||||
.await?
|
||||
@@ -107,19 +134,7 @@ pub async fn create_account(
|
||||
let id = Uuid::now_v7();
|
||||
let now = state.clock.now_ts();
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \
|
||||
deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(&name)
|
||||
.bind(&key)
|
||||
.bind(device.user_id.to_string())
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_account(&mut tx, id, &name, device.user_id, &now, version).await?;
|
||||
replace_shares(&mut tx, id, &shares).await?;
|
||||
let account = reload(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Recurring templates: E-28 to E-30 (FR-9) and the generation of due periods (SPEC §9.5, D-015).
|
||||
//!
|
||||
//! [`generate_due_periods`] is the one function that creates the expenses of a template's due periods. E-28 and E-29
|
||||
//! call it before responding; M4's scheduler calls it for every template (or [`generate_all_due`]).
|
||||
//! call it before responding; the daily scheduler (`tasks::scheduler`) calls [`generate_all_due`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -13,7 +13,8 @@ use sqlx::SqliteConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
|
||||
use crate::api::rows::{self, Origin};
|
||||
use crate::api::{ApiJson, IdPath, JsonBody, validate};
|
||||
use crate::audit::AuditEvent;
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::db;
|
||||
@@ -23,7 +24,8 @@ use crate::push::{PushMessage, PushType};
|
||||
use crate::time::{STOCKHOLM, format_ts};
|
||||
use crate::wire::{
|
||||
AuditAction, AuditSource, CreateRecurringTemplateRequest, EntityType,
|
||||
PatchRecurringTemplateRequest, RecurringTemplate, SplitMode, TemplateSplit, User, UuidSchema,
|
||||
PatchRecurringTemplateRequest, PushedExpense, RecurringTemplate, Split, SplitMode,
|
||||
TemplateSplit, User, UuidSchema,
|
||||
};
|
||||
|
||||
/// Local time of day at which a period's expense is created (SPEC §9.5, AC-9.2).
|
||||
@@ -56,12 +58,12 @@ pub struct GeneratedExpense {
|
||||
pub period: String,
|
||||
}
|
||||
|
||||
/// The expense splits of a generated expense: `(user_id, share_ore, share_bp)`.
|
||||
/// The expense splits of a generated expense.
|
||||
async fn resolve_splits(
|
||||
conn: &mut SqliteConnection,
|
||||
template: &RecurringTemplate,
|
||||
users: &[User],
|
||||
) -> Result<Vec<(Uuid, i64, Option<i64>)>, AppError> {
|
||||
) -> Result<Vec<Split>, AppError> {
|
||||
let ids = rows::user_ids(users);
|
||||
let payer = template.payer_user_id.to_string();
|
||||
let bp: BTreeMap<String, u32> = match template.split_mode {
|
||||
@@ -116,7 +118,13 @@ async fn resolve_splits(
|
||||
})?;
|
||||
return shares
|
||||
.into_iter()
|
||||
.map(|(user, ore)| Ok((rows::uuid(&user)?, ore, None)))
|
||||
.map(|(user, share_ore)| {
|
||||
Ok(Split {
|
||||
user_id: rows::uuid(&user)?,
|
||||
share_ore,
|
||||
share_bp: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
@@ -130,13 +138,84 @@ async fn resolve_splits(
|
||||
.map_err(|e| AppError::internal(format!("stored template split is invalid: {e}")))?;
|
||||
shares
|
||||
.into_iter()
|
||||
.map(|(user, ore)| {
|
||||
.map(|(user, share_ore)| {
|
||||
let share_bp = bp.get(&user).map(|v| i64::from(*v));
|
||||
Ok((rows::uuid(&user)?, ore, share_bp))
|
||||
Ok(Split {
|
||||
user_id: rows::uuid(&user)?,
|
||||
share_ore,
|
||||
share_bp,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Creates the expense of one due `period` of `template` inside the caller's transaction, with its audit entry
|
||||
/// (see [`generate_due_periods`]). Returns the new expense id.
|
||||
async fn create_period_expense(
|
||||
state: &AppState,
|
||||
conn: &mut SqliteConnection,
|
||||
template: &RecurringTemplate,
|
||||
period: Period,
|
||||
day: u8,
|
||||
now_ts: &str,
|
||||
) -> Result<Uuid, AppError> {
|
||||
let users = rows::complete_household(conn).await?;
|
||||
let splits = resolve_splits(conn, template, &users).await?;
|
||||
let period_text = period.to_string();
|
||||
let expense = PushedExpense {
|
||||
id: Uuid::now_v7(),
|
||||
account_id: template.account_id,
|
||||
category_id: template.category_id,
|
||||
amount_ore: template.amount_ore,
|
||||
description: template.description.clone(),
|
||||
notes: template.notes.clone(),
|
||||
expense_date: period.clamped_date(day).map_err(AppError::internal)?,
|
||||
payer_user_id: template.payer_user_id,
|
||||
split_mode: template.split_mode,
|
||||
splits,
|
||||
created_at: now_ts.to_owned(),
|
||||
updated_at: now_ts.to_owned(),
|
||||
deleted_at: None,
|
||||
};
|
||||
let version = db::next_server_version(conn).await?;
|
||||
rows::insert_expense(
|
||||
conn,
|
||||
&expense,
|
||||
Some((template.id, &period_text)),
|
||||
Origin {
|
||||
user_id: template.created_by_user_id,
|
||||
device_id: None,
|
||||
import_key: None,
|
||||
received_at: now_ts,
|
||||
server_version: version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let stored = rows::expense(conn, expense.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::internal("generated expense vanished"))?;
|
||||
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;
|
||||
state
|
||||
.audit
|
||||
.write(
|
||||
conn,
|
||||
AuditEvent {
|
||||
entity_type: EntityType::Expense,
|
||||
entity_id: expense.id,
|
||||
action: AuditAction::Create,
|
||||
source: AuditSource::Recurring,
|
||||
actor_user_id: template.created_by_user_id,
|
||||
actor_device_id: None,
|
||||
client_at: now_ts.to_owned(),
|
||||
server_at: now_ts.to_owned(),
|
||||
before: None,
|
||||
after,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(expense.id)
|
||||
}
|
||||
|
||||
/// Generates every due period of one template (SPEC §9.5), oldest first, each in its own `BEGIN IMMEDIATE`
|
||||
/// transaction: the expense (server UUIDv7, `expense_date` the clamped date, fields copied from the template,
|
||||
/// created and updated by the template's creator, `updated_by_device_id` null), its splits (mode `default` resolves
|
||||
@@ -183,72 +262,11 @@ pub async fn generate_due_periods(
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let now_ts = format_ts(now);
|
||||
let mut created = None;
|
||||
if !exists {
|
||||
let users = rows::complete_household(&mut tx).await?;
|
||||
let splits = resolve_splits(&mut tx, &template, &users).await?;
|
||||
let expense_id = Uuid::now_v7();
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
|
||||
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
|
||||
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
|
||||
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(expense_id.to_string())
|
||||
.bind(template.account_id.to_string())
|
||||
.bind(template.category_id.to_string())
|
||||
.bind(template.amount_ore)
|
||||
.bind(&template.description)
|
||||
.bind(&template.notes)
|
||||
.bind(period.clamped_date(day).map_err(AppError::internal)?)
|
||||
.bind(template.payer_user_id.to_string())
|
||||
.bind(rows::token_of(&template.split_mode)?)
|
||||
.bind(template_id.to_string())
|
||||
.bind(&period_text)
|
||||
.bind(template.created_by_user_id.to_string())
|
||||
.bind(template.created_by_user_id.to_string())
|
||||
.bind(&now_ts)
|
||||
.bind(&now_ts)
|
||||
.bind(&now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for (user_id, share_ore, share_bp) in &splits {
|
||||
sqlx::query(
|
||||
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(expense_id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(share_ore)
|
||||
.bind(share_bp)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let expense = rows::expense(&mut tx, expense_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::internal("generated expense vanished"))?;
|
||||
let after = serde_json::to_value(&expense).map_err(AppError::internal)?;
|
||||
state
|
||||
.audit
|
||||
.write(
|
||||
&mut tx,
|
||||
AuditEvent {
|
||||
entity_type: EntityType::Expense,
|
||||
entity_id: expense_id,
|
||||
action: AuditAction::Create,
|
||||
source: AuditSource::Recurring,
|
||||
actor_user_id: template.created_by_user_id,
|
||||
actor_device_id: None,
|
||||
client_at: now_ts.clone(),
|
||||
server_at: now_ts.clone(),
|
||||
before: None,
|
||||
after,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
created = Some(expense_id);
|
||||
}
|
||||
let created = if exists {
|
||||
None
|
||||
} else {
|
||||
Some(create_period_expense(state, &mut tx, &template, period, day, &now_ts).await?)
|
||||
};
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
sqlx::query(
|
||||
"UPDATE recurring_templates SET last_generated_period = ?, updated_at = ?, server_version = ? WHERE id = ?",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Reading stored rows into their wire form (SPEC §14.3.3), shared by the M3 handlers and the recurring generation.
|
||||
//! Stored rows (SPEC §14.1): reading them into their wire form (SPEC §14.3.3) and the inserts that more than one
|
||||
//! writer shares (REST handlers, recurring generation, sync push, the importer).
|
||||
//!
|
||||
//! Every function takes a connection, so it works on the pool (via `acquire`) and inside a write transaction.
|
||||
|
||||
@@ -11,8 +12,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::wire::{
|
||||
Account, Category, Device, Expense, RecurringTemplate, Role, Share, Split, SplitMode,
|
||||
TemplateSplit, User,
|
||||
Account, Allocation, AuditChange, AuditEntry, Category, Device, Expense, PushedExpense,
|
||||
PushedSettlement, RecurringTemplate, Role, Settlement, Share, Split, SplitMode, TemplateSplit,
|
||||
User,
|
||||
};
|
||||
|
||||
/// Parses a stored UUID.
|
||||
@@ -25,7 +27,7 @@ fn opt_uuid(text: Option<String>) -> Result<Option<Uuid>, AppError> {
|
||||
}
|
||||
|
||||
/// Parses a stored snake_case enum token through its serde form.
|
||||
fn token<T: DeserializeOwned>(text: &str) -> Result<T, AppError> {
|
||||
pub fn token<T: DeserializeOwned>(text: &str) -> Result<T, AppError> {
|
||||
serde_json::from_value(serde_json::Value::String(text.to_owned()))
|
||||
.map_err(|e| AppError::internal(format!("stored token {text:?}: {e}")))
|
||||
}
|
||||
@@ -546,3 +548,257 @@ pub fn bp_shares(
|
||||
.map_err(|e| fail(e.token()))?;
|
||||
Ok(bp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Settlements
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct SettlementRow {
|
||||
id: String,
|
||||
from_user_id: String,
|
||||
to_user_id: String,
|
||||
amount_ore: i64,
|
||||
settlement_date: String,
|
||||
note: String,
|
||||
created_by_user_id: String,
|
||||
updated_by_user_id: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
deleted_at: Option<String>,
|
||||
server_version: i64,
|
||||
}
|
||||
|
||||
/// One settlement (tombstones included), with its allocations sorted by `account_id`.
|
||||
pub async fn settlement(
|
||||
conn: &mut SqliteConnection,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Settlement>, AppError> {
|
||||
let Some(row) = sqlx::query_as::<_, SettlementRow>(
|
||||
"SELECT id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, \
|
||||
updated_by_user_id, created_at, updated_at, deleted_at, server_version FROM settlements WHERE id = ?",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let allocations: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT account_id, amount_ore FROM settlement_allocations WHERE settlement_id = ? ORDER BY account_id",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let mut settlement = Settlement {
|
||||
id: uuid(&row.id)?,
|
||||
from_user_id: uuid(&row.from_user_id)?,
|
||||
to_user_id: uuid(&row.to_user_id)?,
|
||||
amount_ore: row.amount_ore,
|
||||
settlement_date: row.settlement_date,
|
||||
note: row.note,
|
||||
allocations: allocations
|
||||
.into_iter()
|
||||
.map(|(account_id, amount_ore)| {
|
||||
Ok(Allocation {
|
||||
account_id: uuid(&account_id)?,
|
||||
amount_ore,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, AppError>>()?,
|
||||
created_by_user_id: uuid(&row.created_by_user_id)?,
|
||||
updated_by_user_id: uuid(&row.updated_by_user_id)?,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
deleted_at: row.deleted_at,
|
||||
server_version: row.server_version,
|
||||
};
|
||||
settlement.sort();
|
||||
Ok(Some(settlement))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Audit entries and the epoch
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct AuditRow {
|
||||
id: String,
|
||||
entity_type: String,
|
||||
entity_id: String,
|
||||
action: String,
|
||||
source: String,
|
||||
actor_user_id: String,
|
||||
client_at: String,
|
||||
server_at: String,
|
||||
changes: String,
|
||||
server_version: i64,
|
||||
}
|
||||
|
||||
/// One audit entry in its wire form (SPEC §14.3.3; `actor_device_id` is not on the wire).
|
||||
pub async fn audit_entry(
|
||||
conn: &mut SqliteConnection,
|
||||
id: Uuid,
|
||||
) -> Result<Option<AuditEntry>, AppError> {
|
||||
let Some(row) = sqlx::query_as::<_, AuditRow>(
|
||||
"SELECT id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, \
|
||||
server_version FROM audit_log WHERE id = ?",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let changes: Vec<AuditChange> = serde_json::from_str(&row.changes)
|
||||
.map_err(|e| AppError::internal(format!("stored audit changes: {e}")))?;
|
||||
Ok(Some(AuditEntry {
|
||||
id: uuid(&row.id)?,
|
||||
entity_type: token(&row.entity_type)?,
|
||||
entity_id: uuid(&row.entity_id)?,
|
||||
action: token(&row.action)?,
|
||||
source: token(&row.source)?,
|
||||
actor_user_id: uuid(&row.actor_user_id)?,
|
||||
client_at: row.client_at,
|
||||
server_at: row.server_at,
|
||||
changes,
|
||||
server_version: row.server_version,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `meta.sync_epoch`.
|
||||
pub async fn epoch(conn: &mut SqliteConnection) -> Result<String, AppError> {
|
||||
let value: Option<String> =
|
||||
sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
value.ok_or_else(|| AppError::internal("meta.sync_epoch is missing"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Inserts shared by several writers
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// The server-owned columns of a new expense or settlement row.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Origin<'a> {
|
||||
/// `created_by_user_id` and `updated_by_user_id`.
|
||||
pub user_id: Uuid,
|
||||
/// `updated_by_device_id`: the pushing device; `None` for recurring generation and the importer.
|
||||
pub device_id: Option<Uuid>,
|
||||
/// `import_key` (the importer only).
|
||||
pub import_key: Option<&'a str>,
|
||||
/// `server_received_at`: server now.
|
||||
pub received_at: &'a str,
|
||||
/// The row's `server_version`, taken by the caller in its write transaction.
|
||||
pub server_version: i64,
|
||||
}
|
||||
|
||||
/// Inserts an expense and its splits (a tombstone when `deleted_at` is set). `recurring` is the generating template
|
||||
/// and period (`recurring_template_id`, `recurring_period`).
|
||||
pub async fn insert_expense(
|
||||
conn: &mut SqliteConnection,
|
||||
expense: &PushedExpense,
|
||||
recurring: Option<(Uuid, &str)>,
|
||||
origin: Origin<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
|
||||
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
|
||||
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
|
||||
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(expense.id.to_string())
|
||||
.bind(expense.account_id.to_string())
|
||||
.bind(expense.category_id.to_string())
|
||||
.bind(expense.amount_ore)
|
||||
.bind(&expense.description)
|
||||
.bind(&expense.notes)
|
||||
.bind(&expense.expense_date)
|
||||
.bind(expense.payer_user_id.to_string())
|
||||
.bind(token_of(&expense.split_mode)?)
|
||||
.bind(recurring.map(|(template_id, _)| template_id.to_string()))
|
||||
.bind(recurring.map(|(_, period)| period))
|
||||
.bind(origin.import_key)
|
||||
.bind(origin.user_id.to_string())
|
||||
.bind(origin.user_id.to_string())
|
||||
.bind(origin.device_id.map(|id| id.to_string()))
|
||||
.bind(&expense.created_at)
|
||||
.bind(&expense.updated_at)
|
||||
.bind(&expense.deleted_at)
|
||||
.bind(origin.received_at)
|
||||
.bind(origin.server_version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_expense_splits(conn, expense.id, &expense.splits).await
|
||||
}
|
||||
|
||||
/// Inserts the `expense_splits` rows of an expense.
|
||||
pub async fn insert_expense_splits(
|
||||
conn: &mut SqliteConnection,
|
||||
expense_id: Uuid,
|
||||
splits: &[Split],
|
||||
) -> Result<(), AppError> {
|
||||
for split in splits {
|
||||
sqlx::query(
|
||||
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(expense_id.to_string())
|
||||
.bind(split.user_id.to_string())
|
||||
.bind(split.share_ore)
|
||||
.bind(split.share_bp)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a settlement and its allocations (a tombstone when `deleted_at` is set).
|
||||
pub async fn insert_settlement(
|
||||
conn: &mut SqliteConnection,
|
||||
settlement: &PushedSettlement,
|
||||
origin: Origin<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
|
||||
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
|
||||
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(settlement.id.to_string())
|
||||
.bind(settlement.from_user_id.to_string())
|
||||
.bind(settlement.to_user_id.to_string())
|
||||
.bind(settlement.amount_ore)
|
||||
.bind(&settlement.settlement_date)
|
||||
.bind(&settlement.note)
|
||||
.bind(origin.import_key)
|
||||
.bind(origin.user_id.to_string())
|
||||
.bind(origin.user_id.to_string())
|
||||
.bind(origin.device_id.map(|id| id.to_string()))
|
||||
.bind(&settlement.created_at)
|
||||
.bind(&settlement.updated_at)
|
||||
.bind(&settlement.deleted_at)
|
||||
.bind(origin.received_at)
|
||||
.bind(origin.server_version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_allocations(conn, settlement.id, &settlement.allocations).await
|
||||
}
|
||||
|
||||
/// Inserts the `settlement_allocations` rows of a settlement.
|
||||
pub async fn insert_allocations(
|
||||
conn: &mut SqliteConnection,
|
||||
settlement_id: Uuid,
|
||||
allocations: &[Allocation],
|
||||
) -> Result<(), AppError> {
|
||||
for allocation in allocations {
|
||||
sqlx::query(
|
||||
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(settlement_id.to_string())
|
||||
.bind(allocation.account_id.to_string())
|
||||
.bind(allocation.amount_ore)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
pub mod check;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod rows;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ use crate::error::{AppError, ErrorCode};
|
||||
use crate::time::format_ts;
|
||||
use crate::wire::SyncPullResponse;
|
||||
|
||||
use super::rows as sync_rows;
|
||||
|
||||
/// Rows per page when `limit` is absent (SPEC §14.4.9).
|
||||
pub const DEFAULT_LIMIT: i64 = 500;
|
||||
/// Largest `limit` (SPEC §9.6).
|
||||
@@ -47,7 +45,7 @@ pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result<SyncPullRe
|
||||
)
|
||||
.with_field("since"));
|
||||
}
|
||||
let epoch = sync_rows::epoch(&mut tx).await?;
|
||||
let epoch = rows::epoch(&mut tx).await?;
|
||||
// One row more than the page tells whether more exist.
|
||||
let mut stream: Vec<(i64, String, String)> = sqlx::query_as(ROW_STREAM)
|
||||
.bind(since)
|
||||
@@ -103,12 +101,12 @@ pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result<SyncPullRe
|
||||
.ok_or_else(|| missing("expenses"))?,
|
||||
),
|
||||
"settlements" => page.settlements.push(
|
||||
sync_rows::settlement(&mut tx, id)
|
||||
rows::settlement(&mut tx, id)
|
||||
.await?
|
||||
.ok_or_else(|| missing("settlements"))?,
|
||||
),
|
||||
"audit_log" => page.audit_entries.push(
|
||||
sync_rows::audit_entry(&mut tx, id)
|
||||
rows::audit_entry(&mut tx, id)
|
||||
.await?
|
||||
.ok_or_else(|| missing("audit_log"))?,
|
||||
),
|
||||
|
||||
@@ -12,7 +12,7 @@ use sqlx::{Acquire, SqliteConnection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::rows;
|
||||
use crate::api::rows::{self, Origin};
|
||||
use crate::audit::AuditEvent;
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::db;
|
||||
@@ -26,7 +26,6 @@ use crate::wire::{
|
||||
};
|
||||
|
||||
use super::check::{self, Fail, Rejection};
|
||||
use super::rows as sync_rows;
|
||||
|
||||
/// Mutations per `POST /sync` (SPEC §14.4.9).
|
||||
pub const MAX_MUTATIONS: usize = 100;
|
||||
@@ -241,32 +240,7 @@ impl Kind for ExpenseKind {
|
||||
version: i64,
|
||||
now_ts: &str,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
|
||||
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
|
||||
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
|
||||
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(p.account_id.to_string())
|
||||
.bind(p.category_id.to_string())
|
||||
.bind(p.amount_ore)
|
||||
.bind(&p.description)
|
||||
.bind(&p.notes)
|
||||
.bind(&p.expense_date)
|
||||
.bind(p.payer_user_id.to_string())
|
||||
.bind(rows::token_of(&p.split_mode)?)
|
||||
.bind(caller.user_id.to_string())
|
||||
.bind(caller.user_id.to_string())
|
||||
.bind(caller.device_id.to_string())
|
||||
.bind(&p.created_at)
|
||||
.bind(&p.updated_at)
|
||||
.bind(&p.deleted_at)
|
||||
.bind(now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_splits(conn, p).await
|
||||
rows::insert_expense(conn, p, None, origin(caller, version, now_ts)).await
|
||||
}
|
||||
|
||||
async fn update(
|
||||
@@ -299,23 +273,19 @@ impl Kind for ExpenseKind {
|
||||
.bind(p.id.to_string())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_splits(conn, p).await
|
||||
rows::insert_expense_splits(conn, p.id, &p.splits).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_splits(conn: &mut SqliteConnection, p: &PushedExpense) -> Result<(), AppError> {
|
||||
for split in &p.splits {
|
||||
sqlx::query(
|
||||
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(split.user_id.to_string())
|
||||
.bind(split.share_ore)
|
||||
.bind(split.share_bp)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
/// The server-owned columns of a row the caller pushes.
|
||||
fn origin<'a>(caller: &AuthDevice, version: i64, now_ts: &'a str) -> Origin<'a> {
|
||||
Origin {
|
||||
user_id: caller.user_id,
|
||||
device_id: Some(caller.device_id),
|
||||
import_key: None,
|
||||
received_at: now_ts,
|
||||
server_version: version,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Kind for SettlementKind {
|
||||
@@ -345,7 +315,7 @@ impl Kind for SettlementKind {
|
||||
}
|
||||
|
||||
async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result<Option<Settlement>, AppError> {
|
||||
sync_rows::settlement(conn, id).await
|
||||
rows::settlement(conn, id).await
|
||||
}
|
||||
|
||||
async fn check(
|
||||
@@ -364,28 +334,7 @@ impl Kind for SettlementKind {
|
||||
version: i64,
|
||||
now_ts: &str,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
|
||||
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
|
||||
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(p.from_user_id.to_string())
|
||||
.bind(p.to_user_id.to_string())
|
||||
.bind(p.amount_ore)
|
||||
.bind(&p.settlement_date)
|
||||
.bind(&p.note)
|
||||
.bind(caller.user_id.to_string())
|
||||
.bind(caller.user_id.to_string())
|
||||
.bind(caller.device_id.to_string())
|
||||
.bind(&p.created_at)
|
||||
.bind(&p.updated_at)
|
||||
.bind(&p.deleted_at)
|
||||
.bind(now_ts)
|
||||
.bind(version)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_allocations(conn, p).await
|
||||
rows::insert_settlement(conn, p, origin(caller, version, now_ts)).await
|
||||
}
|
||||
|
||||
async fn update(
|
||||
@@ -415,27 +364,10 @@ impl Kind for SettlementKind {
|
||||
.bind(p.id.to_string())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
insert_allocations(conn, p).await
|
||||
rows::insert_allocations(conn, p.id, &p.allocations).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_allocations(
|
||||
conn: &mut SqliteConnection,
|
||||
p: &PushedSettlement,
|
||||
) -> Result<(), AppError> {
|
||||
for allocation in &p.allocations {
|
||||
sqlx::query(
|
||||
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(p.id.to_string())
|
||||
.bind(allocation.account_id.to_string())
|
||||
.bind(allocation.amount_ore)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The outcome of one mutation: its result and the push it causes, if any.
|
||||
struct Processed {
|
||||
result: MutationResult,
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
//! Loading the rows sync needs that `api::rows` does not cover: settlements, audit entries and the epoch.
|
||||
|
||||
use sqlx::{FromRow, SqliteConnection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::rows::uuid;
|
||||
use crate::error::AppError;
|
||||
use crate::wire::{
|
||||
Allocation, AuditAction, AuditChange, AuditEntry, AuditSource, EntityType, Settlement,
|
||||
};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct SettlementRow {
|
||||
id: String,
|
||||
from_user_id: String,
|
||||
to_user_id: String,
|
||||
amount_ore: i64,
|
||||
settlement_date: String,
|
||||
note: String,
|
||||
created_by_user_id: String,
|
||||
updated_by_user_id: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
deleted_at: Option<String>,
|
||||
server_version: i64,
|
||||
}
|
||||
|
||||
/// One settlement (tombstones included), with its allocations sorted by `account_id`.
|
||||
pub async fn settlement(
|
||||
conn: &mut SqliteConnection,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Settlement>, AppError> {
|
||||
let Some(row) = sqlx::query_as::<_, SettlementRow>(
|
||||
"SELECT id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, \
|
||||
updated_by_user_id, created_at, updated_at, deleted_at, server_version FROM settlements WHERE id = ?",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let allocations: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT account_id, amount_ore FROM settlement_allocations WHERE settlement_id = ? ORDER BY account_id",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let mut settlement = Settlement {
|
||||
id: uuid(&row.id)?,
|
||||
from_user_id: uuid(&row.from_user_id)?,
|
||||
to_user_id: uuid(&row.to_user_id)?,
|
||||
amount_ore: row.amount_ore,
|
||||
settlement_date: row.settlement_date,
|
||||
note: row.note,
|
||||
allocations: allocations
|
||||
.into_iter()
|
||||
.map(|(account_id, amount_ore)| {
|
||||
Ok(Allocation {
|
||||
account_id: uuid(&account_id)?,
|
||||
amount_ore,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, AppError>>()?,
|
||||
created_by_user_id: uuid(&row.created_by_user_id)?,
|
||||
updated_by_user_id: uuid(&row.updated_by_user_id)?,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
deleted_at: row.deleted_at,
|
||||
server_version: row.server_version,
|
||||
};
|
||||
settlement.sort();
|
||||
Ok(Some(settlement))
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct AuditRow {
|
||||
id: String,
|
||||
entity_type: String,
|
||||
entity_id: String,
|
||||
action: String,
|
||||
source: String,
|
||||
actor_user_id: String,
|
||||
client_at: String,
|
||||
server_at: String,
|
||||
changes: String,
|
||||
server_version: i64,
|
||||
}
|
||||
|
||||
fn entity_type(text: &str) -> Result<EntityType, AppError> {
|
||||
match text {
|
||||
"expense" => Ok(EntityType::Expense),
|
||||
"settlement" => Ok(EntityType::Settlement),
|
||||
other => Err(AppError::internal(format!(
|
||||
"unknown audit entity_type {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn action(text: &str) -> Result<AuditAction, AppError> {
|
||||
match text {
|
||||
"create" => Ok(AuditAction::Create),
|
||||
"update" => Ok(AuditAction::Update),
|
||||
"delete" => Ok(AuditAction::Delete),
|
||||
other => Err(AppError::internal(format!(
|
||||
"unknown audit action {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Result<AuditSource, AppError> {
|
||||
match text {
|
||||
"app" => Ok(AuditSource::App),
|
||||
"recurring" => Ok(AuditSource::Recurring),
|
||||
"import" => Ok(AuditSource::Import),
|
||||
other => Err(AppError::internal(format!(
|
||||
"unknown audit source {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// One audit entry in its wire form (SPEC §14.3.3; `actor_device_id` is not on the wire).
|
||||
pub async fn audit_entry(
|
||||
conn: &mut SqliteConnection,
|
||||
id: Uuid,
|
||||
) -> Result<Option<AuditEntry>, AppError> {
|
||||
let Some(row) = sqlx::query_as::<_, AuditRow>(
|
||||
"SELECT id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, \
|
||||
server_version FROM audit_log WHERE id = ?",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let changes: Vec<AuditChange> = serde_json::from_str(&row.changes)
|
||||
.map_err(|e| AppError::internal(format!("stored audit changes: {e}")))?;
|
||||
Ok(Some(AuditEntry {
|
||||
id: uuid(&row.id)?,
|
||||
entity_type: entity_type(&row.entity_type)?,
|
||||
entity_id: uuid(&row.entity_id)?,
|
||||
action: action(&row.action)?,
|
||||
source: source(&row.source)?,
|
||||
actor_user_id: uuid(&row.actor_user_id)?,
|
||||
client_at: row.client_at,
|
||||
server_at: row.server_at,
|
||||
changes,
|
||||
server_version: row.server_version,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `meta.sync_epoch`.
|
||||
pub async fn epoch(conn: &mut SqliteConnection) -> Result<String, AppError> {
|
||||
let value: Option<String> =
|
||||
sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
value.ok_or_else(|| AppError::internal("meta.sync_epoch is missing"))
|
||||
}
|
||||
Reference in New Issue
Block a user