M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,83 @@
|
||||
//! Accounts: E-18 to E-22 (FR-6).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
//! Names are unique among non-deleted accounts, compared case-insensitively after trimming through `name_key`
|
||||
//! (Unicode lowercase, AC-6.2). A ratio change affects only expenses created afterwards (AC-6.3): expenses store
|
||||
//! their own shares. Delete is a soft delete, refused while anything references the account (AC-6.5).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use sqlx::SqliteConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::db;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
|
||||
use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema};
|
||||
use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, Share, User, UuidSchema};
|
||||
|
||||
/// `name_key`: the trimmed name, Unicode-lowercased (SPEC §14.1).
|
||||
pub fn name_key(trimmed_name: &str) -> String {
|
||||
trimmed_name.to_lowercase()
|
||||
}
|
||||
|
||||
fn validate_shares(shares: &[Share], users: &[User]) -> Result<Vec<(Uuid, i64)>, AppError> {
|
||||
let entries: Vec<(Uuid, i64)> = shares.iter().map(|s| (s.user_id, s.share_bp)).collect();
|
||||
rows::bp_shares("default_shares", &entries, users)?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// `name_taken` when another non-deleted account has the same `name_key`.
|
||||
async fn check_name_free(
|
||||
conn: &mut SqliteConnection,
|
||||
key: &str,
|
||||
except: Option<Uuid>,
|
||||
) -> Result<(), AppError> {
|
||||
let taken: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM accounts WHERE name_key = ? AND deleted_at IS NULL AND id IS NOT ?)",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(except.map(|id| id.to_string()))
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if taken {
|
||||
Err(
|
||||
AppError::new(ErrorCode::NameTaken, "an account with this name exists")
|
||||
.with_field("name"),
|
||||
)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_shares(
|
||||
conn: &mut SqliteConnection,
|
||||
account_id: Uuid,
|
||||
shares: &[(Uuid, i64)],
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM account_default_shares WHERE account_id = ?")
|
||||
.bind(account_id.to_string())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for (user_id, share_bp) in shares {
|
||||
sqlx::query(
|
||||
"INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(account_id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.bind(share_bp)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result<Account, AppError> {
|
||||
rows::account(conn, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::internal("account vanished inside its transaction"))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -22,8 +92,38 @@ use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn create_account(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn create_account(
|
||||
device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
ApiJson(body): ApiJson<CreateAccountRequest>,
|
||||
) -> Result<(StatusCode, JsonBody<Account>), AppError> {
|
||||
let mut tx = db::begin_immediate(&state.db).await?;
|
||||
let users = rows::complete_household(&mut tx).await?;
|
||||
let name = validate::text("name", &body.name, 1, validate::ACCOUNT_NAME_MAX)?;
|
||||
let shares = validate_shares(&body.default_shares, &users)?;
|
||||
let key = name_key(&name);
|
||||
check_name_free(&mut tx, &key, None).await?;
|
||||
|
||||
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?;
|
||||
replace_shares(&mut tx, id, &shares).await?;
|
||||
let account = reload(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok((StatusCode::CREATED, JsonBody(account)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -43,8 +143,83 @@ pub async fn create_account(_device: AuthDevice) -> AppError {
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn patch_account(
|
||||
_device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
IdPath(account_id): IdPath,
|
||||
ApiJson(body): ApiJson<PatchAccountRequest>,
|
||||
) -> Result<JsonBody<Account>, AppError> {
|
||||
if body.name.is_none() && body.default_shares.is_none() {
|
||||
return Err(AppError::invalid_request("PATCH needs at least one field"));
|
||||
}
|
||||
let mut tx = db::begin_immediate(&state.db).await?;
|
||||
rows::live_account(&mut tx, account_id).await?;
|
||||
let name = body
|
||||
.name
|
||||
.as_deref()
|
||||
.map(|n| validate::text("name", n, 1, validate::ACCOUNT_NAME_MAX))
|
||||
.transpose()?;
|
||||
let shares = match &body.default_shares {
|
||||
Some(shares) => {
|
||||
let users = rows::household(&mut tx).await?;
|
||||
Some(validate_shares(shares, &users)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let Some(name) = &name {
|
||||
check_name_free(&mut tx, &name_key(name), Some(account_id)).await?;
|
||||
}
|
||||
|
||||
let now = state.clock.now_ts();
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
if let Some(name) = &name {
|
||||
sqlx::query("UPDATE accounts SET name = ?, name_key = ? WHERE id = ?")
|
||||
.bind(name)
|
||||
.bind(name_key(name))
|
||||
.bind(account_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if let Some(shares) = &shares {
|
||||
replace_shares(&mut tx, account_id, shares).await?;
|
||||
}
|
||||
sqlx::query("UPDATE accounts SET updated_at = ?, server_version = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(version)
|
||||
.bind(account_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let account = reload(&mut tx, account_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(JsonBody(account))
|
||||
}
|
||||
|
||||
/// E-20 / E-21: sets or clears `archived_at`; a no-op (same row, same `server_version`) when already in that state.
|
||||
async fn set_archived(
|
||||
state: &AppState,
|
||||
account_id: Uuid,
|
||||
archived: bool,
|
||||
) -> Result<JsonBody<Account>, AppError> {
|
||||
let mut tx = db::begin_immediate(&state.db).await?;
|
||||
let account = rows::live_account(&mut tx, account_id).await?;
|
||||
if account.archived_at.is_some() == archived {
|
||||
tx.commit().await?;
|
||||
return Ok(JsonBody(account));
|
||||
}
|
||||
let now = state.clock.now_ts();
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET archived_at = ?, updated_at = ?, server_version = ? WHERE id = ?",
|
||||
)
|
||||
.bind(archived.then_some(&now))
|
||||
.bind(&now)
|
||||
.bind(version)
|
||||
.bind(account_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let account = reload(&mut tx, account_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(JsonBody(account))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -61,8 +236,12 @@ pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) ->
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn archive_account(
|
||||
_device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
IdPath(account_id): IdPath,
|
||||
) -> Result<JsonBody<Account>, AppError> {
|
||||
set_archived(&state, account_id, true).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -79,8 +258,12 @@ pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn unarchive_account(
|
||||
_device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
IdPath(account_id): IdPath,
|
||||
) -> Result<JsonBody<Account>, AppError> {
|
||||
set_archived(&state, account_id, false).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -98,6 +281,40 @@ pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath)
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn delete_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn delete_account(
|
||||
_device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
IdPath(account_id): IdPath,
|
||||
) -> Result<JsonBody<Account>, AppError> {
|
||||
let mut tx = db::begin_immediate(&state.db).await?;
|
||||
rows::live_account(&mut tx, account_id).await?;
|
||||
let referenced: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM expenses WHERE account_id = ?1 AND deleted_at IS NULL) \
|
||||
OR EXISTS (SELECT 1 FROM settlement_allocations a JOIN settlements s ON s.id = a.settlement_id \
|
||||
WHERE a.account_id = ?1 AND s.deleted_at IS NULL) \
|
||||
OR EXISTS (SELECT 1 FROM recurring_templates WHERE account_id = ?1 AND deleted_at IS NULL)",
|
||||
)
|
||||
.bind(account_id.to_string())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if referenced {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::AccountNotEmpty,
|
||||
"the account is referenced by an expense, a settlement or a recurring template",
|
||||
));
|
||||
}
|
||||
let now = state.clock.now_ts();
|
||||
let version = db::next_server_version(&mut tx).await?;
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET deleted_at = ?, updated_at = ?, server_version = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(version)
|
||||
.bind(account_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let account = reload(&mut tx, account_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(JsonBody(account))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user