Skip to main content

vta_cli_common/commands/
boards.rs

1//! A member's side of a community board (`vtc/boards/*`, VTI-BRD-010 – 060): read the boards,
2//! propose and withdraw ideas, add and withdraw notes, and take the tickets for a board's open
3//! ideas — with every key in the member's VTA.
4//!
5//! **The VTA signs as the member.** Each document is built here unsigned, checked against its
6//! published schema, and handed to the VTA's `vault/sign-trust-task`, which signs it as the
7//! principal DID of the member's vault entry for the community — their identity there — with a
8//! key that never leaves the VTA. Exactly what was built comes back, with a proof; anything
9//! else is refused before it reaches the community. This client only carries the signed
10//! document, over the community's HTTPS Trust Task door ([`VtcClient::send_signed_document`]):
11//! a member-signed document never rides this client's own session.
12//!
13//! **Voting is not a board task.** An open idea is an election (`election.electionId`); its
14//! ticket and ballot go through `pnm elections`, the member's VTA and the ballot box, never
15//! through the board. `take-tickets` takes each open idea's ticket in its own request, a random
16//! pause apart, so the community does not receive them as one burst (VTI-BRD-034).
17//!
18//! A facilitator's acts (`create`, `update`, `decide`, `select`, `hide`) are `cnm boards`, signed
19//! with an operator key — never a member's portal or wallet key (VTI-BRD-051).
20
21use std::time::Duration;
22
23use rand::RngExt as _;
24use serde_json::{Value, json};
25use vta_sdk::client::VtaClient;
26use vtc_client::VtcClient;
27use vtc_client::boards::{self as wire, task};
28
29use crate::commands::elections::{self, VoteTarget};
30use crate::render::{BOLD, DIM, GREEN, RESET, YELLOW, is_json_output, print_json};
31
32type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
33
34/// The community, and who the member is there.
35pub struct BoardTarget<'a> {
36    /// Base URL of the community's Trust Task endpoint (e.g. `https://vtc.example.com/v1`).
37    pub vtc_url: &'a str,
38    /// The community's DID.
39    pub vtc_did: &'a str,
40    /// The member's DID at the community: the principal of a vault entry this VTA holds.
41    pub member: &'a str,
42    /// The vault entry to sign as. Found from the community and `member` when absent.
43    pub entry: Option<&'a str>,
44    /// The VTA context the member's voter keys are derived in (`take-tickets` only).
45    pub context: Option<&'a str>,
46}
47
48/// The random pause between two ticket requests of one `take-tickets`, `[min, max)` seconds.
49const TICKET_SPACING_SECS: (u64, u64) = (1, 6);
50
51// ── Signing and sending ─────────────────────────────────────────────────────
52
53/// The vault entry the member signs as at this community: the one named, or the single active
54/// entry for the community whose principal is `member`.
55async fn entry_id(client: &VtaClient, t: &BoardTarget<'_>) -> CliResult<String> {
56    if let Some(e) = t.entry {
57        return Ok(e.to_string());
58    }
59    let listed = client
60        .vault_list(json!({ "targetDid": t.vtc_did, "status": "active" }))
61        .await?;
62    let ours: Vec<&str> = listed["entries"]
63        .as_array()
64        .map(Vec::as_slice)
65        .unwrap_or_default()
66        .iter()
67        .filter(|e| e["principalDid"].as_str() == Some(t.member))
68        .filter_map(|e| e["id"].as_str())
69        .collect();
70    match ours.as_slice() {
71        [one] => Ok((*one).to_string()),
72        [] => Err(format!(
73            "your VTA holds no vault entry for {} as {}. Sign in to the community once from your \
74             wallet, or name the entry with --entry (list them with \
75             `echo '{{\"targetDid\":\"{}\"}}' | pnm vault list --filters-file -`).",
76            t.vtc_did, t.member, t.vtc_did
77        )
78        .into()),
79        many => Err(format!(
80            "your VTA holds {} vault entries for {} as {}: name one with --entry ({}).",
81            many.len(),
82            t.vtc_did,
83            t.member,
84            many.join(", ")
85        )
86        .into()),
87    }
88}
89
90/// The signed envelope is the one built, with a proof added: nothing else is sent on.
91fn same_but_proof(built: &Value, signed: &Value) -> bool {
92    let mut s = signed.clone();
93    let Some(obj) = s.as_object_mut() else {
94        return false;
95    };
96    obj.remove("proof").is_some() && &s == built
97}
98
99/// Build `payload` as `type_uri` from the member to the community, have the VTA sign it as the
100/// member, and send it. The answer's payload.
101async fn send(
102    client: &VtaClient,
103    t: &BoardTarget<'_>,
104    entry: &str,
105    type_uri: &str,
106    payload: Value,
107) -> CliResult<Value> {
108    let payload = wire::checked(type_uri, payload)?;
109    let built = vta_sdk::trust_task_sign::build_unsigned(type_uri, payload, t.member, t.vtc_did)
110        .map_err(|e| format!("could not build the {type_uri} document: {e:?}"))?;
111    let built = serde_json::to_value(&built)?;
112    let signed = client
113        .vault_sign_trust_task(json!({ "entryId": entry, "unsignedEnvelope": built }))
114        .await
115        .map_err(|e| sign_refusal(e.to_string()))?;
116    let signed = &signed["signedEnvelope"];
117    if !same_but_proof(&built, signed) {
118        return Err(
119            "your VTA returned a document other than the one asked for: not sending it".into(),
120        );
121    }
122    VtcClient::anonymous(t.vtc_url, t.vtc_did)
123        .send_signed_document(signed)
124        .await
125        .map_err(|e| format!("{}: {e}", t.vtc_did).into())
126}
127
128fn sign_refusal(text: String) -> Box<dyn std::error::Error> {
129    if text.contains("SignTrustTask") || text.contains("permission") {
130        return format!(
131            "{text}\n\nYour VTA did not let this client sign as you: it needs the SignTrustTask \
132             capability on this client's ACL entry."
133        )
134        .into();
135    }
136    text.into()
137}
138
139/// One task, from first to last page, collecting `field`.
140async fn all_pages(
141    client: &VtaClient,
142    t: &BoardTarget<'_>,
143    entry: &str,
144    type_uri: &str,
145    base: Value,
146    field: &str,
147) -> CliResult<(Value, Vec<Value>)> {
148    let mut items = Vec::new();
149    let mut cursor: Option<String> = None;
150    loop {
151        let mut body = base.clone();
152        body["limit"] = json!(200);
153        if let Some(c) = &cursor {
154            body["cursor"] = json!(c);
155        }
156        let page = send(client, t, entry, type_uri, body).await?;
157        items.extend(page[field].as_array().cloned().unwrap_or_default());
158        match page["nextCursor"].as_str() {
159            Some(c) => cursor = Some(c.to_string()),
160            None => return Ok((page, items)),
161        }
162    }
163}
164
165// ── Rendering ───────────────────────────────────────────────────────────────
166
167fn author(a: &Value) -> String {
168    if a["formerMember"] == json!(true) {
169        "a former member".into()
170    } else if a["you"] == json!(true) {
171        "you".into()
172    } else {
173        a["name"].as_str().unwrap_or("a member").to_string()
174    }
175}
176
177fn lane(state: &str) -> &'static str {
178    match state {
179        "proposed" => "Proposed",
180        "open" => "Open for votes",
181        "selected" => "Selected",
182        "notSelected" => "Not this time",
183        "declined" => "Declined",
184        "merged" => "Merged",
185        _ => "Withdrawn",
186    }
187}
188
189/// An idea's election as one line: its stage, the ballots on its board, or its result.
190fn election_line(e: &Value) -> String {
191    if let Some(r) = e.get("result").filter(|r| !r.is_null()) {
192        let counted = r["ballotsCounted"].as_u64().unwrap_or(0);
193        let choice = &r["questions"][0]["choice"];
194        let total: u64 = choice
195            .as_object()
196            .map(|m| m.values().filter_map(Value::as_u64).sum())
197            .unwrap_or(0);
198        let yes = choice["yes"].as_u64();
199        let share = match (yes, total) {
200            (Some(y), t) if t > 0 => format!(" · {}% yes", y * 100 / t),
201            _ => String::new(),
202        };
203        let certified = if e["certified"] == json!(true) {
204            " · certified"
205        } else {
206            " · not certified"
207        };
208        return format!("{counted} counted{share}{certified}");
209    }
210    match e["ballotsOnBoard"].as_u64() {
211        Some(n) => format!(
212            "{} · {n} ballots on the board",
213            e["state"].as_str().unwrap_or("")
214        ),
215        None => e["state"].as_str().unwrap_or("").to_string(),
216    }
217}
218
219fn print_idea(i: &Value) {
220    let id = i["ideaId"].as_str().unwrap_or("");
221    let title = i["title"].as_str().unwrap_or("");
222    let mut meta = vec![author(&i["proposer"])];
223    for k in ["format", "topic"] {
224        if let Some(v) = i[k].as_str() {
225            meta.push(v.to_string());
226        }
227    }
228    meta.push(format!("{} notes", i["noteCount"].as_u64().unwrap_or(0)));
229    println!("  {BOLD}{title}{RESET}  {DIM}{id}{RESET}");
230    println!("    {DIM}{}{RESET}", meta.join(" · "));
231    if let Some(e) = i.get("election").filter(|e| !e.is_null()) {
232        println!(
233            "    election {} — {}",
234            e["electionId"].as_str().unwrap_or(""),
235            election_line(e)
236        );
237    } else if i.get("opening").is_some_and(|o| !o.is_null()) {
238        println!("    {YELLOW}its election is opening{RESET}");
239    }
240    if let Some(d) = i.get("decision").filter(|d| !d.is_null())
241        && let Some(r) = d["reason"].as_str()
242    {
243        println!("    {DIM}{r}{RESET}");
244    }
245}
246
247// ── Commands ────────────────────────────────────────────────────────────────
248
249/// `pnm boards list`: the boards the member may see.
250pub async fn cmd_list(client: &VtaClient, t: BoardTarget<'_>, status: Option<&str>) -> CliResult {
251    let entry = entry_id(client, &t).await?;
252    let mut base = json!({});
253    if let Some(s) = status {
254        base["status"] = json!(s);
255    }
256    let (_, boards) = all_pages(client, &t, &entry, task::LIST, base, "boards").await?;
257    if is_json_output() {
258        return Ok(print_json(&json!({ "boards": boards }))?);
259    }
260    if boards.is_empty() {
261        println!("{DIM}there are no boards you can see{RESET}");
262    }
263    for b in &boards {
264        println!(
265            "{BOLD}{}{RESET}  {DIM}{}{RESET}",
266            b["title"].as_str().unwrap_or(""),
267            b["boardId"].as_str().unwrap_or("")
268        );
269        println!(
270            "  {} · proposals until {} · votes close {}",
271            b["status"].as_str().unwrap_or(""),
272            b["proposals"]["closesAt"].as_str().unwrap_or(""),
273            b["voting"]["closesAt"].as_str().unwrap_or("")
274        );
275    }
276    Ok(())
277}
278
279/// `pnm boards show <board>`: the board and its ideas, lane by lane.
280pub async fn cmd_show(
281    client: &VtaClient,
282    t: BoardTarget<'_>,
283    board_id: &str,
284    states: &[String],
285) -> CliResult {
286    let entry = entry_id(client, &t).await?;
287    let mut base = json!({ "boardId": board_id });
288    if !states.is_empty() {
289        base["state"] = json!(states);
290    }
291    let (page, ideas) = all_pages(client, &t, &entry, task::SHOW, base, "ideas").await?;
292    let board = &page["board"];
293    if is_json_output() {
294        return Ok(print_json(&json!({ "board": board, "ideas": ideas }))?);
295    }
296    println!("{BOLD}{}{RESET}", board["title"].as_str().unwrap_or(""));
297    if let Some(d) = board["description"].as_str() {
298        println!("{DIM}{d}{RESET}");
299    }
300    println!(
301        "{} · proposals {} → {} · voting {} → {}",
302        board["status"].as_str().unwrap_or(""),
303        board["proposals"]["opensAt"].as_str().unwrap_or(""),
304        board["proposals"]["closesAt"].as_str().unwrap_or(""),
305        board["voting"]["opensAt"].as_str().unwrap_or(""),
306        board["voting"]["closesAt"].as_str().unwrap_or("")
307    );
308    if let Some(r) = board["selectionRule"].as_str() {
309        println!("{DIM}how ideas are chosen: {r}{RESET}");
310    }
311    for state in [
312        "proposed",
313        "open",
314        "selected",
315        "notSelected",
316        "declined",
317        "merged",
318        "withdrawn",
319    ] {
320        let lane_ideas: Vec<&Value> = ideas.iter().filter(|i| i["state"] == state).collect();
321        if lane_ideas.is_empty() {
322            continue;
323        }
324        println!("\n{BOLD}{}{RESET} ({})", lane(state), lane_ideas.len());
325        for i in lane_ideas {
326            print_idea(i);
327        }
328    }
329    Ok(())
330}
331
332/// What a proposal or a change to one says.
333#[derive(Default)]
334pub struct Proposal<'a> {
335    pub title: Option<&'a str>,
336    pub description: Option<&'a str>,
337    pub format: Option<&'a str>,
338    pub topic: Option<&'a str>,
339}
340
341impl Proposal<'_> {
342    fn into_body(self, mut body: Value) -> Value {
343        for (k, v) in [
344            ("title", self.title),
345            ("description", self.description),
346            ("format", self.format),
347            ("topic", self.topic),
348        ] {
349            if let Some(v) = v {
350                body[k] = json!(v);
351            }
352        }
353        body
354    }
355}
356
357/// What the member is told before they first write on a board (VTI-BRD-070).
358fn disclose() {
359    if !is_json_output() {
360        eprintln!(
361            "{DIM}What you write is shown under your name to everyone who can see this board, and \
362             its facilitators can decline, merge or hide it. Voting on an idea is a separate \
363             election: the board never learns whether you voted.{RESET}"
364        );
365    }
366}
367
368fn print_one(label: &str, answer: &Value, key: &str) -> CliResult {
369    if is_json_output() {
370        return Ok(print_json(answer)?);
371    }
372    let v = &answer[key];
373    let id = v["ideaId"]
374        .as_str()
375        .or_else(|| v["noteId"].as_str())
376        .unwrap_or("");
377    println!("{GREEN}{label}{RESET} {BOLD}{id}{RESET}");
378    if key == "idea" {
379        print_idea(v);
380    }
381    Ok(())
382}
383
384/// `pnm boards propose <board> --title … --description …`.
385pub async fn cmd_propose(
386    client: &VtaClient,
387    t: BoardTarget<'_>,
388    board_id: &str,
389    proposal: Proposal<'_>,
390) -> CliResult {
391    let entry = entry_id(client, &t).await?;
392    disclose();
393    let body = proposal.into_body(json!({ "boardId": board_id }));
394    let answer = send(client, &t, &entry, task::IDEAS_PROPOSE, body).await?;
395    print_one("proposed", &answer, "idea")
396}
397
398/// `pnm boards update <idea> …`: change the member's own idea while it is still proposed.
399pub async fn cmd_update(
400    client: &VtaClient,
401    t: BoardTarget<'_>,
402    idea_id: &str,
403    proposal: Proposal<'_>,
404) -> CliResult {
405    let entry = entry_id(client, &t).await?;
406    let body = proposal.into_body(json!({ "ideaId": idea_id }));
407    let answer = send(client, &t, &entry, task::IDEAS_UPDATE, body).await?;
408    print_one("updated", &answer, "idea")
409}
410
411/// `pnm boards withdraw <idea>`.
412pub async fn cmd_withdraw(client: &VtaClient, t: BoardTarget<'_>, idea_id: &str) -> CliResult {
413    let entry = entry_id(client, &t).await?;
414    let answer = send(
415        client,
416        &t,
417        &entry,
418        task::IDEAS_WITHDRAW,
419        json!({ "ideaId": idea_id }),
420    )
421    .await?;
422    print_one("withdrawn", &answer, "idea")
423}
424
425/// `pnm boards notes <board> [--idea <idea>] [--all]`.
426pub async fn cmd_notes(
427    client: &VtaClient,
428    t: BoardTarget<'_>,
429    board_id: &str,
430    idea_id: Option<&str>,
431    all: bool,
432) -> CliResult {
433    let entry = entry_id(client, &t).await?;
434    let mut base = json!({ "boardId": board_id });
435    match idea_id {
436        Some(i) => base["ideaId"] = json!(i),
437        None => base["scope"] = json!(if all { "all" } else { "board" }),
438    }
439    let (_, notes) = all_pages(client, &t, &entry, task::NOTES_LIST, base, "notes").await?;
440    if is_json_output() {
441        return Ok(print_json(&json!({ "notes": notes }))?);
442    }
443    if notes.is_empty() {
444        println!("{DIM}no notes{RESET}");
445    }
446    for n in &notes {
447        let text = match (n["state"].as_str(), n["text"].as_str()) {
448            (Some("visible"), Some(t)) => t.to_string(),
449            (Some("hidden"), _) => format!(
450                "{YELLOW}hidden by a facilitator: {}{RESET}",
451                n["hiddenReason"].as_str().unwrap_or("")
452            ),
453            _ => format!("{DIM}withdrawn{RESET}"),
454        };
455        println!("  {text}");
456        println!(
457            "    {DIM}{} · {} · {}{RESET}",
458            author(&n["author"]),
459            n["createdAt"].as_str().unwrap_or(""),
460            n["noteId"].as_str().unwrap_or("")
461        );
462    }
463    Ok(())
464}
465
466/// `pnm boards note <board> <text> [--idea <idea>]`: add a note.
467pub async fn cmd_note(
468    client: &VtaClient,
469    t: BoardTarget<'_>,
470    board_id: &str,
471    text: &str,
472    idea_id: Option<&str>,
473) -> CliResult {
474    let entry = entry_id(client, &t).await?;
475    disclose();
476    let mut body = json!({ "boardId": board_id, "text": text });
477    if let Some(i) = idea_id {
478        body["ideaId"] = json!(i);
479    }
480    let answer = send(client, &t, &entry, task::NOTES_ADD, body).await?;
481    print_one("added note", &answer, "note")
482}
483
484/// `pnm boards withdraw-note <note>`.
485pub async fn cmd_withdraw_note(client: &VtaClient, t: BoardTarget<'_>, note_id: &str) -> CliResult {
486    let entry = entry_id(client, &t).await?;
487    let answer = send(
488        client,
489        &t,
490        &entry,
491        task::NOTES_WITHDRAW,
492        json!({ "noteId": note_id }),
493    )
494    .await?;
495    print_one("withdrew note", &answer, "note")
496}
497
498/// `pnm boards take-tickets <board>`: the ticket for every idea open for votes, each its own
499/// request, a random pause apart (VTI-BRD-034). One that fails does not stop the rest.
500pub async fn cmd_take_tickets(client: &VtaClient, t: BoardTarget<'_>, board_id: &str) -> CliResult {
501    let context = t
502        .context
503        .ok_or("take-tickets needs --context: the VTA context your voter keys live in")?;
504    let entry = entry_id(client, &t).await?;
505    let (_, ideas) = all_pages(
506        client,
507        &t,
508        &entry,
509        task::SHOW,
510        json!({ "boardId": board_id, "state": ["open"] }),
511        "ideas",
512    )
513    .await?;
514    let elections: Vec<(String, String)> = ideas
515        .iter()
516        .filter_map(|i| {
517            Some((
518                i["election"]["electionId"].as_str()?.to_string(),
519                i["title"].as_str().unwrap_or("").to_string(),
520            ))
521        })
522        .collect();
523    let vote = VoteTarget {
524        vtc_url: t.vtc_url,
525        vtc_did: t.vtc_did,
526        member: t.member,
527        context,
528    };
529    let mut outcomes = Vec::new();
530    for (n, (election_id, title)) in elections.iter().enumerate() {
531        if n > 0 {
532            let (lo, hi) = TICKET_SPACING_SECS;
533            let secs = rand::rng().random_range(lo..hi);
534            tokio::time::sleep(Duration::from_secs(secs)).await;
535        }
536        let outcome = match elections::ticket_for(client, &vote, election_id).await {
537            Ok(true) => json!({ "electionId": election_id, "outcome": "taken" }),
538            Ok(false) => json!({ "electionId": election_id, "outcome": "held" }),
539            Err(e) => {
540                json!({ "electionId": election_id, "outcome": "failed", "error": e.to_string() })
541            }
542        };
543        if !is_json_output() {
544            let word = match outcome["outcome"].as_str() {
545                Some("taken") => format!("{GREEN}ticket taken{RESET}"),
546                Some("held") => format!("{DIM}already held{RESET}"),
547                _ => format!(
548                    "{YELLOW}failed{RESET}: {}",
549                    outcome["error"].as_str().unwrap_or("")
550                ),
551            };
552            println!("  {title} — {word}");
553        }
554        outcomes.push(outcome);
555    }
556    if is_json_output() {
557        return Ok(print_json(&json!({ "tickets": outcomes }))?);
558    }
559    if elections.is_empty() {
560        println!("{DIM}no idea on this board is open for votes yet{RESET}");
561    } else {
562        println!(
563            "{DIM}vote later, one idea at a time, with `pnm elections vote <electionId> …`: a vote \
564             sent straight after its ticket could be paired with it by when each arrived{RESET}"
565        );
566    }
567    Ok(())
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn a_signed_document_is_the_built_one_with_a_proof_added() {
576        let built = json!({ "id": "urn:uuid:1", "type": "t", "payload": { "a": 1 } });
577        let mut signed = built.clone();
578        signed["proof"] = json!({ "type": "DataIntegrityProof" });
579        assert!(same_but_proof(&built, &signed));
580        // No proof at all.
581        assert!(!same_but_proof(&built, &built));
582        // A member changed on the way.
583        let mut changed = signed.clone();
584        changed["payload"]["a"] = json!(2);
585        assert!(!same_but_proof(&built, &changed));
586    }
587
588    #[test]
589    fn an_election_line_says_the_result_or_the_ballots_on_the_board() {
590        let open = json!({ "electionId": "e", "state": "voting", "ballotsOnBoard": 12 });
591        assert_eq!(election_line(&open), "voting · 12 ballots on the board");
592        let tallied = json!({
593            "electionId": "e", "state": "tallied", "certified": false,
594            "result": { "ballotsCounted": 10, "questions": [{ "choice": { "yes": 7, "no": 3 } }] }
595        });
596        assert_eq!(
597            election_line(&tallied),
598            "10 counted · 70% yes · not certified"
599        );
600    }
601
602    #[test]
603    fn a_proposal_carries_only_what_was_given() {
604        let body = Proposal {
605            title: Some("T"),
606            topic: Some("Identity"),
607            ..Default::default()
608        }
609        .into_body(json!({ "ideaId": "x" }));
610        assert_eq!(
611            body,
612            json!({ "ideaId": "x", "title": "T", "topic": "Identity" })
613        );
614    }
615}