Server gap pass: invite-partner subcommand, CLI and drain tests, push info log (D-113, D-114)
- kvitt-server invite-partner: a partner invite by the admin as E-12 does (cancels the pending one, 72 h, link with ?from=<admin>), exit 2 household full, 3 no admin; README lines in server/ and infra/. - Tests: invite-partner, migrate (schema at SCHEMA_VERSION, idempotent), healthcheck (200, 503, nothing listening), serve SIGTERM drain (in-flight request finishes, new connections refused, exit 0; gives up after SHUTDOWN_DRAIN). - Push sender: one info line "push sent" per message with push_type, devices_sent, devices_targeted and 8-character device-id tails (SPEC §8.7 aggregates); per-device line stays at debug. - tools/contracts-check.py: stale TODO(spec) replaced by the §9.11 CS-5 citation; shapes updated for invite_kind (D-086), app_version_name (D-090) and fid (D-096), 134 checks pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
//! `kvitt-server invite-partner` (D-113): what E-12 `POST /invites` with `kind: partner` does for the admin, run on the
|
||||
//! server host, so a partner can be invited when the admin has no phone at hand to create the invite in the app.
|
||||
//!
|
||||
//! Cancels any pending `partner` invite and creates a new one by the admin (72 hours, same code format), then prints
|
||||
//! its code, the landing link naming the admin (`?from=`, D-087) and the expiry, like `bootstrap`. The household
|
||||
//! already has two users: `household full`, exit 2. No admin yet: `no admin, run bootstrap first`, exit 3. Nothing
|
||||
//! is created in either case. E-12 writes no audit entry (the audit log holds expenses and settlements only, §14.1),
|
||||
//! so neither does this. The code goes to stdout only, never to the log (SPEC §8.7).
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::api::invites::{self, MAX_USERS, NewInvite};
|
||||
use crate::api::rows;
|
||||
use crate::config::Config;
|
||||
use crate::db::{self, Db};
|
||||
use crate::error::AppError;
|
||||
use crate::wire::{InviteKind, Role};
|
||||
|
||||
/// Exit code when the household already has two users (D-113).
|
||||
pub const EXIT_HOUSEHOLD_FULL: u8 = 2;
|
||||
/// Exit code when no admin exists yet (D-113).
|
||||
pub const EXIT_NO_ADMIN: u8 = 3;
|
||||
|
||||
/// What `invite-partner` did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// The new invite and the admin's display name, for the link's `from`.
|
||||
Created {
|
||||
invite: NewInvite,
|
||||
admin_name: String,
|
||||
},
|
||||
HouseholdFull,
|
||||
NoAdmin,
|
||||
}
|
||||
|
||||
/// Creates a `partner` invite by the admin, as E-12 does.
|
||||
pub async fn invite_partner(db: &Db, now: DateTime<Utc>) -> Result<Outcome, AppError> {
|
||||
let mut tx = db::begin_immediate(db).await?;
|
||||
let users = rows::household(&mut tx).await?;
|
||||
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?;
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Ok(Outcome::Created { invite, admin_name }) => {
|
||||
print!("{}", render(&invite, &config.public_url, &admin_name));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Ok(Outcome::HouseholdFull) => {
|
||||
eprintln!("household full");
|
||||
ExitCode::from(EXIT_HOUSEHOLD_FULL)
|
||||
}
|
||||
Ok(Outcome::NoAdmin) => {
|
||||
eprintln!("no admin, run bootstrap first");
|
||||
ExitCode::from(EXIT_NO_ADMIN)
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("invite-partner: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! `kvitt-server` subcommands (SPEC §10.4, exhaustive), all implemented: `serve`, `migrate` and `healthcheck` here,
|
||||
//! `bootstrap` and `pair-device` (M3) and `rotate-epoch` (M4) in their own files.
|
||||
//! `bootstrap` and `pair-device` (M3), `rotate-epoch` (M4) and `invite-partner` (D-113) in their own files.
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod healthcheck;
|
||||
pub mod invite_partner;
|
||||
pub mod pair_device;
|
||||
pub mod rotate_epoch;
|
||||
|
||||
@@ -47,6 +48,8 @@ pub enum Command {
|
||||
#[arg(long = "user", value_name = "USER_ID")]
|
||||
user: String,
|
||||
},
|
||||
/// Create a partner invite by the admin, as E-12 does (D-113). Exit 0 ok, 2 household full, 3 no admin.
|
||||
InvitePartner,
|
||||
/// Set a new random sync_epoch (SPEC §10.9).
|
||||
RotateEpoch,
|
||||
/// GET /health on this host; exit 0 if 200, 1 otherwise.
|
||||
@@ -63,6 +66,7 @@ pub async fn run(command: Command, config: Config) -> ExitCode {
|
||||
Command::Healthcheck => return healthcheck::run(&config).await,
|
||||
Command::Bootstrap => return bootstrap::run(&config).await,
|
||||
Command::PairDevice { user } => return pair_device::run(&config, &user).await,
|
||||
Command::InvitePartner => return invite_partner::run(&config).await,
|
||||
Command::RotateEpoch => return rotate_epoch::run(&config).await,
|
||||
};
|
||||
match result {
|
||||
|
||||
@@ -46,6 +46,19 @@ pub enum Delivery {
|
||||
GaveUp { attempts: usize, detail: String },
|
||||
}
|
||||
|
||||
/// Characters of a device id in the `push sent` info line.
|
||||
pub const SHORT_DEVICE_ID_LEN: usize = 8;
|
||||
|
||||
/// The last [`SHORT_DEVICE_ID_LEN`] characters of a device id, enough to tell the household's devices apart in the
|
||||
/// info log. The tail, not the head: device ids are UUIDv7, whose leading characters are the creation time and are
|
||||
/// shared by devices paired within about a minute of each other.
|
||||
pub fn short_device_id(device_id: &str) -> String {
|
||||
let chars: Vec<char> = device_id.chars().collect();
|
||||
chars[chars.len().saturating_sub(SHORT_DEVICE_ID_LEN)..]
|
||||
.iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The background sender.
|
||||
pub struct Sender {
|
||||
db: Db,
|
||||
@@ -108,18 +121,25 @@ impl Sender {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sends `message` to every recipient. Returns one delivery per recipient, in recipient order.
|
||||
/// Sends `message` to every recipient. Returns one delivery per recipient, in recipient order. When at least one
|
||||
/// device got it, logs one info line `push sent` with aggregates only (SPEC §8.7): the push type, how many devices
|
||||
/// got it out of how many were targeted, and the short device ids of [`short_device_id`].
|
||||
pub async fn deliver(&self, message: &PushMessage) -> Result<Vec<Delivery>, AppError> {
|
||||
let recipients = self.recipients(message).await?;
|
||||
let mut deliveries = Vec::new();
|
||||
for recipient in self.recipients(message).await? {
|
||||
let delivery = self.send_to(&recipient, message).await?;
|
||||
let mut sent = Vec::new();
|
||||
for recipient in &recipients {
|
||||
let delivery = self.send_to(recipient, message).await?;
|
||||
match &delivery {
|
||||
Delivery::Sent { attempts } => tracing::debug!(
|
||||
push_type = message.push_type.as_str(),
|
||||
device_id = %recipient.device_id,
|
||||
attempts,
|
||||
"push sent"
|
||||
),
|
||||
Delivery::Sent { attempts } => {
|
||||
sent.push(short_device_id(&recipient.device_id));
|
||||
tracing::debug!(
|
||||
push_type = message.push_type.as_str(),
|
||||
device_id = %recipient.device_id,
|
||||
attempts,
|
||||
"push sent to device"
|
||||
);
|
||||
}
|
||||
Delivery::TokenCleared => tracing::info!(
|
||||
device_id = %recipient.device_id,
|
||||
"FCM token unregistered: cleared"
|
||||
@@ -134,6 +154,15 @@ impl Sender {
|
||||
}
|
||||
deliveries.push(delivery);
|
||||
}
|
||||
if !sent.is_empty() {
|
||||
tracing::info!(
|
||||
push_type = message.push_type.as_str(),
|
||||
devices_sent = sent.len(),
|
||||
devices_targeted = recipients.len(),
|
||||
devices = %sent.join(","),
|
||||
"push sent"
|
||||
);
|
||||
}
|
||||
Ok(deliveries)
|
||||
}
|
||||
|
||||
@@ -174,3 +203,17 @@ impl Sender {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::short_device_id;
|
||||
|
||||
#[test]
|
||||
fn short_device_id_is_the_tail() {
|
||||
assert_eq!(
|
||||
short_device_id("0192a001-0000-7000-8000-00000000abcd"),
|
||||
"0000abcd"
|
||||
);
|
||||
assert_eq!(short_device_id("abc"), "abc");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user