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:
2026-10-17 22:00:58 +02:00
parent 6dce4c73a9
commit 7869ec48d8
38 changed files with 7623 additions and 141 deletions
+232 -15
View File
@@ -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))
}