M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile

Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
@@ -0,0 +1,103 @@
//! 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`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema};
#[utoipa::path(
post,
path = "/accounts",
operation_id = "createAccount",
summary = "E-18 Create account",
request_body(content = CreateAccountRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The new account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn create_account(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
patch,
path = "/accounts/{account_id}",
operation_id = "patchAccount",
summary = "E-19 Rename, change default ratio",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
request_body(content = PatchAccountRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The updated account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/accounts/{account_id}/archive",
operation_id = "archiveAccount",
summary = "E-20 Archive account",
description = "No body. Idempotent.",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/accounts/{account_id}/unarchive",
operation_id = "unarchiveAccount",
summary = "E-21 Unarchive account",
description = "No body. Idempotent.",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/accounts/{account_id}",
operation_id = "deleteAccount",
summary = "E-22 Soft delete when empty",
description = "Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5).",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The tombstone.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn delete_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError {
not_implemented()
}
+42
View File
@@ -0,0 +1,42 @@
//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::not_implemented;
use crate::error::AppError;
use crate::openapi::responses::NotFound;
use crate::wire::{ApkBytes, ApkLatest, IntSchema};
#[utoipa::path(
get,
path = "/apk/latest",
operation_id = "getApkLatest",
summary = "E-5 Latest APK metadata",
security(()),
responses(
(status = 200, description = "The content of `latest.json` (SPEC §10.6), read on every request.", body = ApkLatest, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn get_apk_latest() -> AppError {
not_implemented()
}
#[utoipa::path(
get,
path = "/apk/files/{file_name}",
operation_id = "getApkFile",
summary = "E-6 APK download",
security(()),
params(
("file_name" = String, Path, description = "Served only when it matches `^kvitt-[0-9]+\\.apk$` and exists (SPEC §10.6)."),
),
responses(
(status = 200, description = "The APK bytes.", body = ApkBytes, content_type = "application/vnd.android.package-archive", headers(("Content-Length" = IntSchema))),
(status = 404, response = NotFound),
)
)]
pub async fn get_apk_file() -> AppError {
not_implemented()
}
@@ -0,0 +1,103 @@
//! Categories: E-23 to E-27 (FR-7).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
use crate::wire::{Category, CreateCategoryRequest, PatchCategoryRequest, UuidSchema};
#[utoipa::path(
post,
path = "/categories",
operation_id = "createCategory",
summary = "E-23 Create category",
description = "New categories get `sort_order` = current maximum + 10 (AC-7.3). `icon` and `color` are tokens of SPEC §9.7.",
request_body(content = CreateCategoryRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The new category.", body = Category, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 422, response = Unprocessable),
)
)]
pub async fn create_category(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
patch,
path = "/categories/{category_id}",
operation_id = "patchCategory",
summary = "E-24 Rename, icon, colour, order, reset name",
description = "`name: null` resets a default category's name. Reordering several categories is one PATCH per category whose `sort_order` changes.",
params(
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
request_body(content = PatchCategoryRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The updated category.", body = Category, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn patch_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/categories/{category_id}/archive",
operation_id = "archiveCategory",
summary = "E-25 Archive category",
description = "No body.",
params(
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn archive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/categories/{category_id}/unarchive",
operation_id = "unarchiveCategory",
summary = "E-26 Unarchive category",
description = "No body.",
params(
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn unarchive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/categories/{category_id}",
operation_id = "deleteCategory",
summary = "E-27 Soft delete when unused and not default",
params(
("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The tombstone.", body = Category, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn delete_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError {
not_implemented()
}
@@ -0,0 +1,58 @@
//! Devices: E-15 list, E-16 revoke, E-17 push token (FR-3, SPEC §8.4).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound};
use crate::wire::{DeviceList, PushTokenRequest, UuidSchema};
#[utoipa::path(
get,
path = "/devices",
operation_id = "listDevices",
summary = "E-15 List devices (admin all, member own)",
responses(
(status = 200, description = "Ordered by `created_at`, revoked devices included (with `revoked_at`).", body = DeviceList, content_type = "application/json; charset=utf-8"),
)
)]
pub async fn list_devices(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/devices/{device_id}",
operation_id = "revokeDevice",
summary = "E-16 Revoke a device",
description = "Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4).",
params(
("device_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 204, description = "Revoked."),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn revoke_device(_device: AuthDevice, IdPath(_device_id): IdPath) -> AppError {
not_implemented()
}
#[utoipa::path(
put,
path = "/devices/current/push-token",
operation_id = "putPushToken",
summary = "E-17 Set or clear this device's FCM token",
request_body(content = PushTokenRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 204, description = "Stored (or cleared)."),
(status = 400, response = BadRequest),
)
)]
pub async fn put_push_token(_device: AuthDevice) -> AppError {
not_implemented()
}
@@ -0,0 +1,60 @@
//! E-1 `GET /health` (FR-20, SPEC §14.3.4).
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use crate::api::JsonBody;
use crate::wire::{Health, HealthStatus};
use crate::{AppState, SERVER_VERSION, db};
/// The database check must answer well within the 100 ms budget of AC-20.1; beyond this it counts as failed.
const DB_CHECK_TIMEOUT: Duration = Duration::from_millis(80);
#[utoipa::path(
get,
path = "/health",
operation_id = "getHealth",
summary = "E-1 Liveness and DB check",
security(()),
responses(
(status = 200, description = "Server and database are up.", body = Health,
content_type = "application/json; charset=utf-8"),
(status = 503, description = "The database check (`SELECT 1`) failed: the `Health` shape with `status` \
`degraded` and `db_ok` false. An `Error` with code `unavailable` when the server is shutting down.",
body = HealthOrError, content_type = "application/json; charset=utf-8"),
)
)]
pub async fn get_health(State(state): State<AppState>) -> Response {
let db_ok = matches!(
tokio::time::timeout(DB_CHECK_TIMEOUT, db::ping(&state.db)).await,
Ok(Ok(()))
);
let body = Health {
status: if db_ok {
HealthStatus::Ok
} else {
HealthStatus::Degraded
},
server_version: SERVER_VERSION.to_owned(),
schema_version: db::SCHEMA_VERSION,
db_ok,
};
let status = if db_ok {
StatusCode::OK
} else {
tracing::warn!(error_code = "unavailable", "health: database check failed");
StatusCode::SERVICE_UNAVAILABLE
};
(status, JsonBody(body)).into_response()
}
/// The 503 body of E-1: the health object, or an `Error` from a server that is shutting down (SPEC §14.3.4 E-1).
#[derive(serde::Serialize, utoipa::ToSchema)]
#[serde(untagged)]
pub enum HealthOrError {
Health(Health),
Error(crate::error::ErrorBody),
}
@@ -0,0 +1,82 @@
//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AdminDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable};
use crate::wire::{
AcceptInviteRequest, AcceptInviteResponse, CreateInviteRequest, Invite, InviteList, UuidSchema,
};
#[utoipa::path(
post,
path = "/invites/accept",
operation_id = "acceptInvite",
summary = "E-3 Pair a device with an invite code (all three invite kinds)",
security(()),
request_body(content = AcceptInviteRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "Paired. The token is shown only here.", body = AcceptInviteResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn accept_invite() -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/invites",
operation_id = "createInvite",
summary = "E-12 Create a partner or device invite (admin)",
description = "Creating a `partner` invite cancels any pending one (AC-2.1).",
request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>`).", body = Invite, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn create_invite(_admin: AdminDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
get,
path = "/invites",
operation_id = "listInvites",
summary = "E-13 List pending invites (admin)",
responses(
(status = 200, description = "Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.", body = InviteList, content_type = "application/json; charset=utf-8"),
(status = 403, response = Forbidden),
)
)]
pub async fn list_invites(_admin: AdminDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/invites/{invite_id}",
operation_id = "cancelInvite",
summary = "E-14 Cancel an invite (admin)",
description = "Idempotent for an already cancelled invite.",
params(
("invite_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 204, description = "Cancelled."),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
)
)]
pub async fn cancel_invite(_admin: AdminDevice, IdPath(_invite_id): IdPath) -> AppError {
not_implemented()
}
@@ -0,0 +1,26 @@
//! E-4 invite landing page (AC-2.5).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::not_implemented;
use crate::error::AppError;
#[utoipa::path(
get,
path = "/i/{code}",
operation_id = "getInviteLanding",
summary = "E-4 Invite landing page (HTML)",
description = "HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when `Accept-Language` prefers `sv`, English otherwise.",
security(()),
params(
("code" = String, Path),
),
responses(
(status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"),
(status = 404, description = "`code` not 1 to 32 characters: an HTML 404 page.", body = String, content_type = "text/html; charset=utf-8"),
)
)]
pub async fn get_invite_landing() -> AppError {
not_implemented()
}
+39
View File
@@ -0,0 +1,39 @@
//! E-7 `GET /me` and E-8 `PATCH /me` (FR-4).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::not_implemented;
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Unprocessable};
use crate::wire::{MeResponse, PatchMeRequest, User};
#[utoipa::path(
get,
path = "/me",
operation_id = "getMe",
summary = "E-7 Current user, device and household users",
responses(
(status = 200, description = "The caller's user and device.", body = MeResponse, content_type = "application/json; charset=utf-8"),
)
)]
pub async fn get_me(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
patch,
path = "/me",
operation_id = "patchMe",
summary = "E-8 Change own display name",
request_body(content = PatchMeRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The updated user.", body = User, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 422, response = Unprocessable),
)
)]
pub async fn patch_me(_device: AuthDevice) -> AppError {
not_implemented()
}
+116
View File
@@ -0,0 +1,116 @@
//! HTTP handlers, one file per area (SPEC §14.7), and the extractors and response types they share.
//!
//! M2a implements `health` (E-1) and `setup` (E-2); every other handler is registered and annotated but returns 501
//! `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) fill in the bodies.
pub mod accounts;
pub mod apk;
pub mod categories;
pub mod devices;
pub mod health;
pub mod invites;
pub mod landing;
pub mod me;
pub mod pictures;
pub mod proposal;
pub mod rate_limit;
pub mod recurring;
pub mod setup;
pub mod sync;
use axum::extract::rejection::{JsonRejection, PathRejection};
use axum::extract::{FromRequest, FromRequestParts, Request};
use axum::http::request::Parts;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use serde::de::DeserializeOwned;
use uuid::Uuid;
use crate::error::AppError;
/// The content type of every JSON body (SPEC §14.3.1).
pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
/// A JSON response body with `Content-Type: application/json; charset=utf-8`.
#[derive(Debug, Clone)]
pub struct JsonBody<T>(pub T);
impl<T: Serialize> IntoResponse for JsonBody<T> {
fn into_response(self) -> Response {
match serde_json::to_vec(&self.0) {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(JSON_CONTENT_TYPE),
);
response
}
Err(e) => {
tracing::error!(detail = %e, "response serialisation failed");
(StatusCode::INTERNAL_SERVER_ERROR, "internal").into_response()
}
}
}
}
/// A JSON request body whose rejections map to the error codes of SPEC §14.3.2: malformed JSON, missing fields and
/// wrong types are 400 `invalid_request`; a body over the limit is 413 `payload_too_large`.
#[derive(Debug, Clone)]
pub struct ApiJson<T>(pub T);
impl<T, S> FromRequest<S> for ApiJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
match axum::Json::<T>::from_request(request, state).await {
Ok(axum::Json(value)) => Ok(Self(value)),
Err(rejection) => Err(json_rejection(rejection)),
}
}
}
fn json_rejection(rejection: JsonRejection) -> AppError {
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
return AppError::new(
crate::error::ErrorCode::PayloadTooLarge,
"request body too large",
);
}
match rejection {
JsonRejection::MissingJsonContentType(_) => {
AppError::invalid_request("Content-Type must be application/json")
}
other => AppError::invalid_request(other.body_text()),
}
}
/// One UUID path segment. Anything that is not a UUID is 404 `not_found` (SPEC §14.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IdPath(pub Uuid);
impl<S: Send + Sync> FromRequestParts<S> for IdPath {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let axum::extract::Path(text): axum::extract::Path<String> =
axum::extract::Path::from_request_parts(parts, state)
.await
.map_err(|_: PathRejection| AppError::not_found())?;
Uuid::parse_str(&text)
.ok()
.filter(|id| id.hyphenated().to_string() == text)
.map(Self)
.ok_or_else(AppError::not_found)
}
}
/// The body of every handler that is registered but not yet implemented (501 `not_implemented`).
pub fn not_implemented() -> AppError {
AppError::not_implemented()
}
@@ -0,0 +1,64 @@
//! Pictures: E-9 upload, E-10 remove, E-11 download (AC-4.4).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{
BadRequest, NotFound, PayloadTooLarge, Unprocessable, UnsupportedMediaType,
};
use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidSchema, WebpBytes};
#[utoipa::path(
put,
path = "/me/picture",
operation_id = "putMePicture",
summary = "E-9 Upload own picture",
description = "JPEG, PNG or WebP up to 5 MiB and 8000 × 8000 px; processed per AC-4.4.",
request_body(content((JpegBytes = "image/jpeg"), (PngBytes = "image/png"), (WebpBytes = "image/webp"))),
responses(
(status = 200, description = "The user with the new `picture_version`.", body = User, content_type = "application/json; charset=utf-8"),
(status = 413, response = PayloadTooLarge),
(status = 415, response = UnsupportedMediaType),
(status = 422, response = Unprocessable),
)
)]
pub async fn put_me_picture(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/me/picture",
operation_id = "deleteMePicture",
summary = "E-10 Remove own picture",
description = "Idempotent.",
responses(
(status = 200, description = "The user with `picture_version` null.", body = User, content_type = "application/json; charset=utf-8"),
)
)]
pub async fn delete_me_picture(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
get,
path = "/users/{user_id}/picture",
operation_id = "getUserPicture",
summary = "E-11 Download a user's picture",
params(
("user_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
("size" = Option<PictureSizeSchema>, Query, description = "Other values are `invalid_request`."),
("v" = Option<IntSchema>, Query, description = "The `picture_version`; a cache key only, ignored by the server."),
),
responses(
(status = 200, description = "The square JPEG.", body = JpegBytes, content_type = "image/jpeg", headers(("ETag" = String, description = "`\"<picture_version>\"`"), ("Cache-Control" = String, description = "`private, max-age=31536000, immutable`"))),
(status = 400, response = BadRequest),
(status = 404, response = NotFound),
)
)]
pub async fn get_user_picture(_device: AuthDevice, IdPath(_user_id): IdPath) -> AppError {
not_implemented()
}
@@ -0,0 +1,28 @@
//! E-33 settlement allocation proposal (SPEC §9.4).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::not_implemented;
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, Unprocessable};
use crate::wire::{ProposalRequest, ProposalResponse};
#[utoipa::path(
post,
path = "/settlements/proposal",
operation_id = "proposeSettlement",
summary = "E-33 Server-side allocation proposal",
description = "Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows.",
request_body(content = ProposalRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The proposal.", body = ProposalResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn propose_settlement(_device: AuthDevice) -> AppError {
not_implemented()
}
@@ -0,0 +1,8 @@
//! Rate limiting (SPEC §8.6). M3 owns this file; in M2a the layer lets every request through.
use tower::layer::util::Identity;
/// The rate-limit layer `router.rs` attaches to every route.
pub fn layer() -> Identity {
Identity::new()
}
@@ -0,0 +1,74 @@
//! Recurring templates: E-28 to E-30 (FR-9, SPEC §9.5).
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::{IdPath, not_implemented};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
use crate::wire::{
CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, UuidSchema,
};
#[utoipa::path(
post,
path = "/recurring-templates",
operation_id = "createRecurringTemplate",
summary = "E-28 Create recurring template",
description = "If a period is already due (SPEC §9.5), the server generates it before responding.",
request_body(content = CreateRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The new template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn create_recurring_template(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
patch,
path = "/recurring-templates/{template_id}",
operation_id = "patchRecurringTemplate",
summary = "E-29 Edit, pause, resume",
description = "Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.",
params(
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
request_body(content = PatchRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The updated template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 404, response = NotFound),
(status = 422, response = Unprocessable),
)
)]
pub async fn patch_recurring_template(
_device: AuthDevice,
IdPath(_template_id): IdPath,
) -> AppError {
not_implemented()
}
#[utoipa::path(
delete,
path = "/recurring-templates/{template_id}",
operation_id = "deleteRecurringTemplate",
summary = "E-30 Soft delete recurring template",
params(
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The tombstone.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn delete_recurring_template(
_device: AuthDevice,
IdPath(_template_id): IdPath,
) -> AppError {
not_implemented()
}
@@ -0,0 +1,30 @@
//! E-2 `GET /setup/status` (AC-1.5): whether the household has been bootstrapped, that is whether the
//! `admin_setup` invite has been accepted and the admin user exists.
use axum::extract::State;
use crate::AppState;
use crate::api::JsonBody;
use crate::error::AppError;
use crate::wire::SetupStatus;
#[utoipa::path(
get,
path = "/setup/status",
operation_id = "getSetupStatus",
summary = "E-2 Whether the household has been bootstrapped",
security(()),
responses(
(status = 200, description = "`initialized` is true once the admin_setup invite has been accepted (AC-1.5).",
body = SetupStatus, content_type = "application/json; charset=utf-8"),
)
)]
pub async fn get_setup_status(
State(state): State<AppState>,
) -> Result<JsonBody<SetupStatus>, AppError> {
let initialized: bool =
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')")
.fetch_one(&state.db)
.await?;
Ok(JsonBody(SetupStatus { initialized }))
}
@@ -0,0 +1,49 @@
//! Sync: E-31 pull and E-32 push (SPEC §14.4).
//!
//! Registered and annotated by M2a; M4 owns this file and implements the handlers (until then 501
//! `not_implemented`).
use crate::api::not_implemented;
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::{BadRequest, Conflict, PayloadTooLarge, Unprocessable};
use crate::wire::{SyncLimitSchema, SyncPullResponse, SyncPushRequest, SyncPushResponse};
#[utoipa::path(
get,
path = "/sync",
operation_id = "syncPull",
summary = "E-31 Pull changes since a cursor",
description = "SPEC §14.4.2.",
params(
("since" = i64, Query, description = "The highest `server_version` the phone has applied (0 initially)."),
("limit" = Option<SyncLimitSchema>, Query, description = "Rows per page, 1 to 1000 (`invalid_request` otherwise)."),
),
responses(
(status = 200, description = "One page. Every array is present (possibly empty) and sorted by `server_version`.", body = SyncPullResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
)
)]
pub async fn sync_pull(_device: AuthDevice) -> AppError {
not_implemented()
}
#[utoipa::path(
post,
path = "/sync",
operation_id = "syncPush",
summary = "E-32 Push local expense and settlement changes",
description = "SPEC §14.4.3 and §14.4.4. 1 to 100 mutations, body at most 1 MiB. One result per mutation, in request order.",
request_body(content = SyncPushRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "Per-mutation results.", body = SyncPushResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 413, response = PayloadTooLarge),
(status = 422, response = Unprocessable),
)
)]
pub async fn sync_push(_device: AuthDevice) -> AppError {
not_implemented()
}