Skip to main content

qualia_client_core/
project_collab.rs

1//! Lightweight **cooperative project registry + collaborator roster** for Talk → Projects.
2//!
3//! Works **without** an unlocked Sanctuary vault so cooperative help is not blocked on first-run
4//! vault setup. When the vault *is* unlocked, Wellfair journal membership remains the durable
5//! clinical/ledger path; this module is the always-available social roster and local project list.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::PathBuf;
11
12use crate::state::app_meta_dir;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ProjectCollaborator {
16    pub project_id: String,
17    pub project_name: String,
18    pub member_did: String,
19    #[serde(default)]
20    pub display_name: String,
21    /// `steward` | `contributor` | `observer` | `agent`
22    pub role: String,
23    pub added_at: u64,
24}
25
26/// A cooperative project that exists on this device (local-first; vault optional).
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct LocalProject {
29    pub id: String,
30    pub name: String,
31    #[serde(default)]
32    pub description: String,
33    pub created_at: u64,
34    /// `local` = created here without vault; `wellfair` = also mirrored to vault journal when unlocked.
35    #[serde(default = "default_source_local")]
36    pub source: String,
37}
38
39fn default_source_local() -> String {
40    "local".into()
41}
42
43/// Summary row for the Projects UI.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ProjectSummary {
46    pub id: String,
47    pub name: String,
48    pub member_count: usize,
49    pub source: String,
50}
51
52fn path() -> PathBuf {
53    app_meta_dir().join("project_collaborators.json")
54}
55
56fn projects_path() -> PathBuf {
57    app_meta_dir().join("coop_projects.json")
58}
59
60fn load() -> Vec<ProjectCollaborator> {
61    fs::read_to_string(path())
62        .ok()
63        .and_then(|t| serde_json::from_str(&t).ok())
64        .unwrap_or_default()
65}
66
67fn save(rows: &[ProjectCollaborator]) -> Result<(), String> {
68    let p = path();
69    if let Some(parent) = p.parent() {
70        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
71    }
72    fs::write(
73        p,
74        serde_json::to_string_pretty(rows).map_err(|e| e.to_string())?,
75    )
76    .map_err(|e| e.to_string())
77}
78
79fn now() -> u64 {
80    std::time::SystemTime::now()
81        .duration_since(std::time::UNIX_EPOCH)
82        .map(|d| d.as_secs())
83        .unwrap_or(0)
84}
85
86/// List collaborators, optionally filtered to one project id.
87pub fn list(project_id: Option<&str>) -> Vec<ProjectCollaborator> {
88    let all = load();
89    match project_id {
90        Some(id) if !id.is_empty() => all.into_iter().filter(|c| c.project_id == id).collect(),
91        _ => all,
92    }
93}
94
95/// Upsert a collaborator on a project (keyed by project_id + member_did).
96pub fn add(
97    project_id: &str,
98    project_name: &str,
99    member_did: &str,
100    display_name: &str,
101    role: &str,
102) -> Result<ProjectCollaborator, String> {
103    let project_id = project_id.trim();
104    let member_did = member_did.trim();
105    if project_id.is_empty() || member_did.is_empty() {
106        return Err("project_id and member_did are required".into());
107    }
108    let role = match role.trim().to_ascii_lowercase().as_str() {
109        "steward" => "steward",
110        "observer" => "observer",
111        "agent" => "agent",
112        _ => "contributor",
113    };
114    let mut all = load();
115    all.retain(|c| !(c.project_id == project_id && c.member_did == member_did));
116    let row = ProjectCollaborator {
117        project_id: project_id.to_string(),
118        project_name: project_name.trim().to_string(),
119        member_did: member_did.to_string(),
120        display_name: display_name.trim().to_string(),
121        role: role.to_string(),
122        added_at: now(),
123    };
124    all.push(row.clone());
125    save(&all)?;
126    Ok(row)
127}
128
129pub fn remove(project_id: &str, member_did: &str) -> Result<(), String> {
130    let mut all = load();
131    let before = all.len();
132    all.retain(|c| !(c.project_id == project_id && c.member_did == member_did));
133    if all.len() == before {
134        return Err("collaborator not found".into());
135    }
136    save(&all)
137}
138
139fn load_projects() -> Vec<LocalProject> {
140    fs::read_to_string(projects_path())
141        .ok()
142        .and_then(|t| serde_json::from_str(&t).ok())
143        .unwrap_or_default()
144}
145
146fn save_projects(rows: &[LocalProject]) -> Result<(), String> {
147    let p = projects_path();
148    if let Some(parent) = p.parent() {
149        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
150    }
151    fs::write(
152        p,
153        serde_json::to_string_pretty(rows).map_err(|e| e.to_string())?,
154    )
155    .map_err(|e| e.to_string())
156}
157
158/// Create (or upsert by name) a **local** cooperative project — no vault required.
159pub fn create_local_project(name: &str, description: &str) -> Result<LocalProject, String> {
160    let name = name.trim();
161    if name.is_empty() {
162        return Err("project name is required".into());
163    }
164    let mut all = load_projects();
165    if let Some(existing) = all.iter().find(|p| p.name.eq_ignore_ascii_case(name)) {
166        return Ok(existing.clone());
167    }
168    let id = format!("local-{}", now());
169    let proj = LocalProject {
170        id: id.clone(),
171        name: name.to_string(),
172        description: description.trim().to_string(),
173        created_at: now(),
174        source: "local".into(),
175    };
176    all.push(proj.clone());
177    save_projects(&all)?;
178    // Steward: local profile DID when available.
179    let profile = crate::user_profile::load_profile();
180    let self_did = if profile.public_did.is_empty() {
181        crate::user_profile::resolve_public_did(&profile)
182    } else {
183        profile.public_did.clone()
184    };
185    if !self_did.is_empty() {
186        let _ = add(&id, name, &self_did, &profile.display_name, "steward");
187    }
188    Ok(proj)
189}
190
191/// Register a wellfair-backed project id into the local registry (after vault create).
192pub fn register_wellfair_project(
193    id: &str,
194    name: &str,
195    description: &str,
196) -> Result<LocalProject, String> {
197    let id = id.trim();
198    let name = name.trim();
199    if id.is_empty() || name.is_empty() {
200        return Err("id and name required".into());
201    }
202    let mut all = load_projects();
203    all.retain(|p| p.id != id);
204    let proj = LocalProject {
205        id: id.to_string(),
206        name: name.to_string(),
207        description: description.trim().to_string(),
208        created_at: now(),
209        source: "wellfair".into(),
210    };
211    all.push(proj.clone());
212    save_projects(&all)?;
213    Ok(proj)
214}
215
216/// List projects for the UI: local registry + any ids seen only in the collaborator roster.
217pub fn list_project_summaries() -> Vec<ProjectSummary> {
218    let mut by_id: BTreeMap<String, ProjectSummary> = BTreeMap::new();
219    for p in load_projects() {
220        let n = list(Some(&p.id)).len();
221        by_id.insert(
222            p.id.clone(),
223            ProjectSummary {
224                id: p.id,
225                name: p.name,
226                member_count: n,
227                source: p.source,
228            },
229        );
230    }
231    for c in load() {
232        by_id
233            .entry(c.project_id.clone())
234            .and_modify(|s| {
235                if s.name.is_empty() && !c.project_name.is_empty() {
236                    s.name = c.project_name.clone();
237                }
238                // recount below
239            })
240            .or_insert_with(|| ProjectSummary {
241                id: c.project_id.clone(),
242                name: if c.project_name.is_empty() {
243                    c.project_id.clone()
244                } else {
245                    c.project_name.clone()
246                },
247                member_count: 0,
248                source: "roster".into(),
249            });
250    }
251    // Final member counts
252    for s in by_id.values_mut() {
253        s.member_count = list(Some(&s.id)).len();
254    }
255    let mut out: Vec<_> = by_id.into_values().collect();
256    out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
257    out
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn add_list_remove() {
266        let pid = format!("board-test-{}", now());
267        let did = format!("did:test:{}", now());
268        let row = add(&pid, "Test Project", &did, "Alice", "contributor").expect("add");
269        assert_eq!(row.project_id, pid);
270        assert!(list(Some(&pid)).iter().any(|c| c.member_did == did));
271        remove(&pid, &did).expect("remove");
272        assert!(!list(Some(&pid)).iter().any(|c| c.member_did == did));
273    }
274
275    #[test]
276    fn create_local_project_and_summaries() {
277        let name = format!("CoopTest-{}", now());
278        let p = create_local_project(&name, "desc").expect("create");
279        assert!(!p.id.is_empty());
280        let again = create_local_project(&name, "desc").expect("idempotent");
281        assert_eq!(p.id, again.id);
282        let sums = list_project_summaries();
283        assert!(sums.iter().any(|s| s.name == name));
284    }
285}