M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

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 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
+27 -11
View File
@@ -9,12 +9,13 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use base64::Engine as _;
use chrono::{DateTime, Duration, Utc};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, sha256_hex};
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, app_version_name, sha256_hex};
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable};
@@ -84,9 +85,18 @@ pub fn generate_token() -> Result<String, AppError> {
))
}
/// `<public_url>/i/<code>`.
pub fn invite_link(public_url: &str, code: &str) -> String {
format!("{public_url}/i/{code}")
/// Query parameter of the landing link and of `kvitt://invite` that names the inviting admin (AC-2.6, S-1 help line).
pub const FROM_PARAM: &str = "from";
/// `<public_url>/i/<code>`, with `?from=<percent-encoded admin display name>` when `from` is given (E-12).
pub fn invite_link(public_url: &str, code: &str, from: Option<&str>) -> String {
match from {
Some(name) => format!(
"{public_url}/i/{code}?{FROM_PARAM}={}",
utf8_percent_encode(name, NON_ALPHANUMERIC)
),
None => format!("{public_url}/i/{code}"),
}
}
/// A freshly created invite with its plain code (shown once).
@@ -101,14 +111,14 @@ pub struct NewInvite {
}
impl NewInvite {
/// The wire form with `code` and `link` (E-12 response).
pub fn wire(&self, public_url: &str) -> Invite {
/// The wire form with `code` and `link` (E-12 response); `from` is the inviting admin's display name.
pub fn wire(&self, public_url: &str, from: Option<&str>) -> Invite {
Invite {
id: self.id,
kind: self.kind,
target_user_id: self.target_user_id,
code: Some(self.code.clone()),
link: Some(invite_link(public_url, &self.code)),
link: Some(invite_link(public_url, &self.code, from)),
created_at: self.created_at.clone(),
expires_at: self.expires_at.clone(),
}
@@ -303,14 +313,15 @@ pub async fn accept_invite(
let token = generate_token()?;
let device_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, created_at, last_seen_at) \
VALUES (?, ?, ?, ?, NULL, ?, ?, ?)",
"INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, app_version_name, \
created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)",
)
.bind(device_id.to_string())
.bind(user_id.to_string())
.bind(&device_name)
.bind(sha256_hex(&token))
.bind(app_version(&headers))
.bind(app_version_name(&headers))
.bind(&now_ts)
.bind(&now_ts)
.execute(&mut *tx)
@@ -333,6 +344,7 @@ pub async fn accept_invite(
tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired");
Ok(JsonBody(AcceptInviteResponse {
token,
invite_kind: kind,
user,
device,
household,
@@ -347,7 +359,7 @@ pub async fn accept_invite(
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 = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<admin display name, percent-encoded>`).", body = Invite, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
@@ -393,10 +405,14 @@ pub async fn create_invite(
.await?
}
};
// The link names the admin so S-1 can say whom the code came from (AC-2.6).
let from = rows::user(&mut tx, admin.user_id)
.await?
.map(|user| user.display_name);
tx.commit().await?;
Ok((
StatusCode::CREATED,
JsonBody(invite.wire(&state.config.public_url)),
JsonBody(invite.wire(&state.config.public_url, from.as_deref())),
))
}