Skip to main content

vta_cli_common/commands/
notifications.rs

1//! `pnm notifications …` — the notifications the VTA holds for its principal
2//! (`vta/notifications/{list,show,dismiss}/0.1`, VTI-NTF-050 – 053), and handing it one a
3//! client received for a DID it holds keys of (`vta/notifications/deposit/0.1`, VTI-NTF-054).
4//!
5//! A notification says only that a thing exists — an election, an invitation — where it is
6//! held, and whom it concerns. It is no evidence that anyone may take part (VTI-NTF-004), and
7//! listing, reading or dismissing one tells nobody anything. Opening the thing is a separate,
8//! deliberate step against its holder (`subject.openWith` sent to `subject.heldBy`), never
9//! taken here (VTI-NTF-053).
10
11use serde_json::{Value, json};
12use vta_sdk::prelude::*;
13
14use crate::render::{BOLD, CYAN, DIM, GREEN, RESET, is_json_output, print_json};
15
16/// Options for `notifications list`.
17#[derive(Debug, Clone, Default)]
18pub struct ListOptions {
19    pub kind: Option<String>,
20    pub held_by: Option<String>,
21    pub include_dismissed: bool,
22    pub include_lapsed: bool,
23    pub limit: Option<u32>,
24    pub cursor: Option<String>,
25}
26
27fn line(item: &Value) -> String {
28    let n = &item["notification"];
29    let mut period = String::new();
30    if let Some(until) = n["validUntil"].as_str() {
31        period = format!(" until {until}");
32    }
33    format!(
34        "{CYAN}{}{RESET}  {BOLD}{}{RESET} {} at {}{}{}  {DIM}from {}{}{RESET}",
35        item["recordId"].as_str().unwrap_or("?"),
36        n["kind"].as_str().unwrap_or("?"),
37        n["subject"]["id"].as_str().unwrap_or("?"),
38        n["subject"]["heldBy"].as_str().unwrap_or("?"),
39        period,
40        match (n["status"].as_str(), item["state"].as_str()) {
41            (Some("withdrawn"), _) => " (withdrawn)",
42            (_, Some("dismissed")) => " (dismissed)",
43            _ => "",
44        },
45        item["originator"].as_str().unwrap_or("?"),
46        match item["relayedBy"].as_array() {
47            Some(r) if !r.is_empty() => format!(
48                ", relayed by {}",
49                r.iter()
50                    .filter_map(Value::as_str)
51                    .collect::<Vec<_>>()
52                    .join(" → ")
53            ),
54            _ => String::new(),
55        },
56    )
57}
58
59/// `list` → `vta/notifications/list/0.1`.
60pub async fn cmd_notifications_list(
61    client: &VtaClient,
62    context: &str,
63    opts: &ListOptions,
64) -> Result<(), Box<dyn std::error::Error>> {
65    let mut req = json!({ "contextId": context });
66    if let Some(k) = &opts.kind {
67        req["kind"] = json!(k);
68    }
69    if let Some(h) = &opts.held_by {
70        req["heldBy"] = json!(h);
71    }
72    if opts.include_dismissed {
73        req["includeDismissed"] = json!(true);
74    }
75    if opts.include_lapsed {
76        req["includeLapsed"] = json!(true);
77    }
78    if let Some(l) = opts.limit {
79        req["limit"] = json!(l);
80    }
81    if let Some(c) = &opts.cursor {
82        req["cursor"] = json!(c);
83    }
84    let resp = client.notifications_list_json(req).await?;
85    if is_json_output() {
86        print_json(&resp)?;
87        return Ok(());
88    }
89    let items = resp["items"].as_array().cloned().unwrap_or_default();
90    if items.is_empty() {
91        println!("No notifications held in context '{context}'.");
92    } else {
93        println!();
94        println!("{BOLD}Notifications in context '{context}'{RESET}");
95        println!();
96        for item in &items {
97            println!("  {}", line(item));
98        }
99        println!();
100    }
101    if let Some(c) = resp["nextCursor"].as_str() {
102        println!("{DIM}More remain: pass --cursor {c}{RESET}");
103    }
104    Ok(())
105}
106
107/// `show` → `vta/notifications/show/0.1`: the record and the delivering document, unchanged.
108pub async fn cmd_notifications_show(
109    client: &VtaClient,
110    context: &str,
111    record_id: &str,
112) -> Result<(), Box<dyn std::error::Error>> {
113    let resp = client
114        .notifications_show_json(json!({ "contextId": context, "recordId": record_id }))
115        .await?;
116    if is_json_output() {
117        print_json(&resp)?;
118        return Ok(());
119    }
120    println!();
121    println!("  {}", line(&resp["item"]));
122    let n = &resp["item"]["notification"];
123    println!(
124        "  {DIM}audience {}  open with {}{RESET}",
125        n["audience"].as_str().unwrap_or("?"),
126        n["subject"]["openWith"].as_str().unwrap_or("?")
127    );
128    println!();
129    println!("{BOLD}Delivering document, as received{RESET}");
130    println!("{}", serde_json::to_string_pretty(&resp["document"])?);
131    Ok(())
132}
133
134/// `dismiss` → `vta/notifications/dismiss/0.1`.
135pub async fn cmd_notifications_dismiss(
136    client: &VtaClient,
137    context: &str,
138    record_id: &str,
139) -> Result<(), Box<dyn std::error::Error>> {
140    let resp = client
141        .notifications_dismiss_json(json!({ "contextId": context, "recordId": record_id }))
142        .await?;
143    if is_json_output() {
144        print_json(&resp)?;
145        return Ok(());
146    }
147    if resp["changed"].as_bool() == Some(true) {
148        println!("{GREEN}\u{2713}{RESET} Dismissed {BOLD}{record_id}{RESET}.");
149    } else {
150        println!("{BOLD}{record_id}{RESET} was already dismissed.");
151    }
152    Ok(())
153}
154
155/// `deposit` → `vta/notifications/deposit/0.1`: `file` (`-` for stdin) holds the document
156/// exactly as received; it is sent as parsed, never through a typed form (VTI-NTF-054).
157pub async fn cmd_notifications_deposit(
158    client: &VtaClient,
159    context: &str,
160    file: &str,
161) -> Result<(), Box<dyn std::error::Error>> {
162    let text = if file == "-" {
163        let mut buf = String::new();
164        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
165        buf
166    } else {
167        std::fs::read_to_string(file)?
168    };
169    let document: Value = serde_json::from_str(&text)
170        .map_err(|e| format!("{file} does not hold a JSON document: {e}"))?;
171    let resp = client
172        .notifications_deposit_json(json!({ "contextId": context, "document": document }))
173        .await?;
174    if is_json_output() {
175        print_json(&resp)?;
176        return Ok(());
177    }
178    println!(
179        "{GREEN}\u{2713}{RESET} Deposited {BOLD}{}{RESET} revision {}. Whether the VTA holds it \
180         is its acceptance policy's decision: `pnm notifications list --context {context}` shows \
181         what it holds.",
182        resp["notificationId"].as_str().unwrap_or("?"),
183        resp["revision"]
184    );
185    Ok(())
186}