1use 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
34pub struct BoardTarget<'a> {
36 pub vtc_url: &'a str,
38 pub vtc_did: &'a str,
40 pub member: &'a str,
42 pub entry: Option<&'a str>,
44 pub context: Option<&'a str>,
46}
47
48const TICKET_SPACING_SECS: (u64, u64) = (1, 6);
50
51async 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
90fn 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
99async 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
139async 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
165fn 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
189fn 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
247pub 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
279pub 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#[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
357fn 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
384pub 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
398pub 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
411pub 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
425pub 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 ¬es {
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
466pub 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
484pub 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
498pub 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 assert!(!same_but_proof(&built, &built));
582 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}