Server overhaul: invite creation and CLI plumbing shared

invites::create_partner_invite is the one partner-invite path of E-12 and invite-partner; E-3's user creation moves
into invited_user; invite kinds parse through rows::token; app_version_code comes from auth. cli::render_invite
replaces the two identical render functions and cli::with_database the open/run/close block of four subcommands.
Same output, exit codes and errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:28:51 +02:00
parent 8103dae2be
commit 4b30888dc2
6 changed files with 136 additions and 144 deletions
@@ -6,12 +6,13 @@
use std::process::ExitCode;
use chrono::{DateTime, Utc};
use crate::api::invites::{self, NewInvite};
use crate::config::Config;
use crate::db::{self, Db};
use crate::error::AppError;
use crate::wire::InviteKind;
use chrono::{DateTime, Utc};
/// Exit code when the household already exists (SPEC §10.4).
pub const EXIT_ALREADY_INITIALIZED: u8 = 2;
@@ -38,28 +39,14 @@ pub async fn bootstrap(db: &Db, now: DateTime<Utc>) -> Result<Outcome, AppError>
Ok(Outcome::Created(invite))
}
/// The text printed for a new invite (AC-1.1, AC-1.6): code in groups of four, landing link, expiry.
pub fn render(invite: &NewInvite, public_url: &str) -> String {
format!(
"Code: {}\nLink: {}\nExpires: {}\n",
invites::display_code(&invite.code),
invites::invite_link(public_url, &invite.code, None),
invite.expires_at
)
}
/// Opens the database and runs [`bootstrap`] with the system clock.
pub async fn run(config: &Config) -> ExitCode {
let result = async {
let pool = super::open_database(config).await?;
let outcome = bootstrap(&pool, Utc::now()).await;
pool.close().await;
outcome
}
.await;
match result {
match super::with_database(config, async |pool| bootstrap(pool, Utc::now()).await).await {
Ok(Outcome::Created(invite)) => {
print!("{}", render(&invite, &config.public_url));
print!(
"{}",
super::render_invite(&invite, &config.public_url, None)
);
ExitCode::SUCCESS
}
Ok(Outcome::AlreadyInitialized) => {
@@ -11,12 +11,12 @@ use std::process::ExitCode;
use chrono::{DateTime, Utc};
use crate::api::invites::{self, MAX_USERS, NewInvite};
use crate::api::invites::{self, NewInvite};
use crate::api::rows;
use crate::config::Config;
use crate::db::{self, Db};
use crate::error::AppError;
use crate::wire::{InviteKind, Role};
use crate::error::{AppError, ErrorCode};
use crate::wire::Role;
/// Exit code when the household already has two users (D-113).
pub const EXIT_HOUSEHOLD_FULL: u8 = 2;
@@ -42,39 +42,26 @@ pub async fn invite_partner(db: &Db, now: DateTime<Utc>) -> Result<Outcome, AppE
let Some(admin) = users.iter().find(|u| u.role == Role::Admin) else {
return Ok(Outcome::NoAdmin);
};
if users.len() >= MAX_USERS {
return Ok(Outcome::HouseholdFull);
}
let (admin_id, admin_name) = (admin.id, admin.display_name.clone());
invites::cancel_pending(&mut tx, InviteKind::Partner, now).await?;
let invite =
invites::insert_invite(&mut tx, InviteKind::Partner, None, Some(admin_id), now).await?;
let invite = match invites::create_partner_invite(&mut tx, admin.id, now).await {
Ok(invite) => invite,
Err(e) if e.code == ErrorCode::HouseholdFull => return Ok(Outcome::HouseholdFull),
Err(e) => return Err(e),
};
tx.commit().await?;
Ok(Outcome::Created { invite, admin_name })
}
/// The printed text: as [`super::bootstrap::render`], with the admin named in the link (D-087).
pub fn render(invite: &NewInvite, public_url: &str, admin_name: &str) -> String {
format!(
"Code: {}\nLink: {}\nExpires: {}\n",
invites::display_code(&invite.code),
invites::invite_link(public_url, &invite.code, Some(admin_name)),
invite.expires_at
)
Ok(Outcome::Created {
invite,
admin_name: admin.display_name.clone(),
})
}
/// Opens the database and runs [`invite_partner`] with the system clock.
pub async fn run(config: &Config) -> ExitCode {
let result = async {
let pool = super::open_database(config).await?;
let outcome = invite_partner(&pool, Utc::now()).await;
pool.close().await;
outcome
}
.await;
match result {
match super::with_database(config, async |pool| invite_partner(pool, Utc::now()).await).await {
Ok(Outcome::Created { invite, admin_name }) => {
print!("{}", render(&invite, &config.public_url, &admin_name));
print!(
"{}",
super::render_invite(&invite, &config.public_url, Some(&admin_name))
);
ExitCode::SUCCESS
}
Ok(Outcome::HouseholdFull) => {
+28 -5
View File
@@ -1,5 +1,4 @@
//! `kvitt-server` subcommands (SPEC §10.4, exhaustive), all implemented: `serve`, `migrate` and `healthcheck` here,
//! `bootstrap` and `pair-device` (M3), `rotate-epoch` (M4) and `invite-partner` (D-113) in their own files.
//! `kvitt-server` subcommands (SPEC §10.4, exhaustive): `serve` and `migrate` here, the others in their own files.
pub mod bootstrap;
pub mod healthcheck;
@@ -18,7 +17,9 @@ use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use tokio::sync::Notify;
use crate::api::invites::{self, NewInvite};
use crate::config::Config;
use crate::db::Db;
use crate::error::AppError;
use crate::{AppState, db, router, tasks};
@@ -79,6 +80,29 @@ pub async fn run(command: Command, config: Config) -> ExitCode {
}
}
/// The text the invite subcommands print (AC-1.1, AC-1.6, D-113): the code in groups of four, the landing link
/// (naming the inviting admin with `from`, D-087) and the expiry. The code goes to stdout only, never to the log
/// (SPEC §8.7).
pub fn render_invite(invite: &NewInvite, public_url: &str, from: Option<&str>) -> String {
format!(
"Code: {}\nLink: {}\nExpires: {}\n",
invites::display_code(&invite.code),
invites::invite_link(public_url, &invite.code, from),
invite.expires_at
)
}
/// Opens the database (see [`open_database`]), runs `job` on it and closes the pool again.
pub async fn with_database<T>(
config: &Config,
job: impl AsyncFnOnce(&Db) -> Result<T, AppError>,
) -> Result<T, AppError> {
let pool = open_database(config).await?;
let result = job(&pool).await;
pool.close().await;
result
}
/// Opens (creating if needed) the data directory and the database, and runs the migrations.
pub async fn open_database(config: &Config) -> Result<db::Db, AppError> {
std::fs::create_dir_all(&config.data_dir).map_err(|e| {
@@ -90,8 +114,7 @@ pub async fn open_database(config: &Config) -> Result<db::Db, AppError> {
}
async fn migrate(config: Config) -> Result<(), AppError> {
let pool = open_database(&config).await?;
pool.close().await;
with_database(&config, async |_| Ok(())).await?;
tracing::info!("migrations applied");
Ok(())
}
@@ -100,7 +123,7 @@ async fn serve(config: Config) -> Result<(), AppError> {
let pool = open_database(&config).await?;
let bind = config.bind;
// `tasks::start` installs the push queue (or logs once that push is disabled) and starts the scheduler.
let state = tasks::start(AppState::new(config, pool.clone())).await?;
let state = tasks::start(AppState::new(config, pool.clone()));
let listener = TcpListener::bind(bind)
.await
@@ -40,16 +40,16 @@ pub async fn pair_device(
/// Opens the database and runs [`pair_device`] with the system clock.
pub async fn run(config: &Config, user_id: &str) -> ExitCode {
let result = async {
let pool = super::open_database(config).await?;
let outcome = pair_device(&pool, user_id, Utc::now()).await;
pool.close().await;
outcome
}
.await;
match result {
match super::with_database(config, async |pool| {
pair_device(pool, user_id, Utc::now()).await
})
.await
{
Ok(Some(invite)) => {
print!("{}", super::bootstrap::render(&invite, &config.public_url));
print!(
"{}",
super::render_invite(&invite, &config.public_url, None)
);
ExitCode::SUCCESS
}
Ok(None) => {
@@ -21,15 +21,9 @@ pub async fn rotate(db: &Db) -> Result<String, AppError> {
Ok(epoch)
}
/// Opens the database and runs [`rotate`].
pub async fn run(config: &Config) -> ExitCode {
let result = async {
let pool = super::open_database(config).await?;
let epoch = rotate(&pool).await;
pool.close().await;
epoch
}
.await;
match result {
match super::with_database(config, async |pool| rotate(pool).await).await {
Ok(epoch) => {
tracing::info!("sync_epoch rotated");
println!("sync_epoch rotated: {epoch}");