use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const PREFIX: &[u8] = b"invitation:";
fn key(id: &str) -> Vec<u8> {
let mut k = PREFIX.to_vec();
k.extend_from_slice(id.as_bytes());
k
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvitationRecord {
pub id: String,
pub subject_did: String,
pub slot: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub issued_by: String,
pub issued_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTime<Utc>>,
}
impl InvitationRecord {
pub fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
}
pub async fn store_invitation(
ks: &KeyspaceHandle,
record: &InvitationRecord,
) -> Result<(), AppError> {
ks.insert(key(&record.id), record).await
}
pub async fn get_invitation(
ks: &KeyspaceHandle,
id: &str,
) -> Result<Option<InvitationRecord>, AppError> {
ks.get::<InvitationRecord>(key(id)).await
}
pub async fn list_invitations(ks: &KeyspaceHandle) -> Result<Vec<InvitationRecord>, AppError> {
let raw = ks.prefix_iter_raw(PREFIX.to_vec()).await?;
let mut out: Vec<InvitationRecord> = Vec::with_capacity(raw.len());
for (_k, v) in raw {
match serde_json::from_slice::<InvitationRecord>(&v) {
Ok(r) => out.push(r),
Err(e) => tracing::warn!(error = %e, "skipping unparseable invitation row"),
}
}
out.sort_by(|a, b| b.issued_at.cmp(&a.issued_at));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn ks() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("invitations").unwrap();
(dir, store, ks)
}
fn rec(id: &str, at: &str) -> InvitationRecord {
InvitationRecord {
id: id.into(),
subject_did: "did:key:zInvitee".into(),
slot: 7,
role: Some("moderator".into()),
issued_by: "did:key:zAdmin".into(),
issued_at: at.parse().unwrap(),
valid_until: None,
revoked_at: None,
}
}
#[tokio::test]
async fn round_trip_and_list_newest_first() {
let (_d, _s, ks) = ks().await;
store_invitation(&ks, &rec("urn:uuid:a", "2026-01-01T00:00:00Z"))
.await
.unwrap();
store_invitation(&ks, &rec("urn:uuid:b", "2026-02-01T00:00:00Z"))
.await
.unwrap();
let got = get_invitation(&ks, "urn:uuid:a").await.unwrap().unwrap();
assert_eq!(got.slot, 7);
assert!(!got.is_revoked());
let all = list_invitations(&ks).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, "urn:uuid:b", "newest first");
}
}