Skip to main content

qualia_client_core/wellfair/api/
coop.rs

1//! Cooperative projects + credentials
2
3use super::super::blob_store::BlobStore;
4use super::super::journal::JournalEntry;
5use wellfare_core::credentials::{
6    build_credential_envelope, build_presentation, credential_summary, CredentialRecord,
7    FieldSelectedPresentation,
8};
9use wellfare_core::projects::{
10    build_contribution_envelope, build_membership_envelope, build_project_envelope,
11    contribution_summary, derive_obligations, membership_summary, project_summary, Contribution,
12    Obligation, Project, ProjectMembership,
13};
14
15use super::*;
16
17impl WebizenHostApi {
18    // --- Cooperative projects (Phase 5 / COP-01..) ---
19
20    pub fn add_project(&mut self, project: &Project) -> Result<JournalEntry, String> {
21        let asserted = Self::now_unix() as u32;
22        let hash =
23            Self::payload_hash_hex(&serde_json::to_string(project).map_err(|e| e.to_string())?);
24        let envelope = build_project_envelope(
25            project,
26            &self.owner_did,
27            &self.author_did,
28            asserted,
29            Some(hash),
30        );
31        let summary = project_summary(project);
32        self.submit_record_with_summary(QAPP_PROJECTS, envelope, SOURCE_PROJECTS, Some(summary))?;
33        self.finalize_batch().ok();
34        self.latest_journal_entry()
35    }
36
37    pub fn add_project_membership(
38        &mut self,
39        membership: &ProjectMembership,
40    ) -> Result<JournalEntry, String> {
41        let asserted = Self::now_unix() as u32;
42        let hash =
43            Self::payload_hash_hex(&serde_json::to_string(membership).map_err(|e| e.to_string())?);
44        let envelope = build_membership_envelope(
45            membership,
46            &self.owner_did,
47            &self.author_did,
48            asserted,
49            Some(hash),
50        );
51        let summary = membership_summary(membership);
52        self.submit_record_with_summary(QAPP_PROJECTS, envelope, SOURCE_PROJECTS, Some(summary))?;
53        self.finalize_batch().ok();
54        self.latest_journal_entry()
55    }
56
57    pub fn add_contribution(
58        &mut self,
59        contribution: &Contribution,
60    ) -> Result<JournalEntry, String> {
61        let asserted = Self::now_unix() as u32;
62        let hash = Self::payload_hash_hex(
63            &serde_json::to_string(contribution).map_err(|e| e.to_string())?,
64        );
65        let envelope = build_contribution_envelope(
66            contribution,
67            &self.owner_did,
68            &self.author_did,
69            asserted,
70            Some(hash),
71        );
72        let summary = contribution_summary(contribution);
73        self.submit_record_with_summary(QAPP_PROJECTS, envelope, SOURCE_PROJECTS, Some(summary))?;
74        self.finalize_batch().ok();
75        self.latest_journal_entry()
76    }
77
78    pub fn list_contributions(&self, limit: usize) -> Result<Vec<JournalEntry>, String> {
79        self.list_journal_by_kind("contribution", limit)
80    }
81
82    /// Locally-committed contributions reconstructed from the journal.
83    fn local_contributions(&self, limit: usize) -> Result<Vec<Contribution>, String> {
84        let mut out = Vec::new();
85        for row in self.list_contributions(limit)? {
86            if let Some(ref summary) = row.summary {
87                if let Some(c) =
88                    contribution_from_summary(row.id.clone(), summary, row.asserted_time_unix)
89                {
90                    out.push(c);
91                }
92            }
93        }
94        Ok(out)
95    }
96
97    /// Derive per-(project, contributor) effort obligations from the committed contribution
98    /// journal. Pure over the unique-id set, so a duplicate or replayed commit can never
99    /// double-count effort (§17 money/obligation safety).
100    pub fn project_obligations(&self, limit: usize) -> Result<Vec<Obligation>, String> {
101        Ok(derive_obligations(&self.local_contributions(limit)?))
102    }
103
104    /// Obligations derived from **both** locally-committed contributions and validated inbound
105    /// sync operations (kind `contribution`) — the cross-node convergence view. Because
106    /// `derive_obligations` collapses to the unique record-id set first, a remote contribution
107    /// that has already been seen locally, or a replayed inbound op, never double-counts effort
108    /// (§17). This is the "apply validated inbound ops" step of the sync loop for obligations.
109    pub fn synced_project_obligations(&self, limit: usize) -> Result<Vec<Obligation>, String> {
110        let mut contributions = self.local_contributions(limit)?;
111        for op in self.validated_sync_operations()? {
112            if op.kind == "contribution" {
113                if let Some(c) = contribution_from_summary(
114                    op.record_id.clone(),
115                    &op.payload_summary,
116                    op.committed_unix,
117                ) {
118                    contributions.push(c);
119                }
120            }
121        }
122        Ok(derive_obligations(&contributions))
123    }
124
125    // --- Credentials (Phase 3/7 / CRE-01..) ---
126
127    pub fn add_credential(
128        &mut self,
129        credential: &CredentialRecord,
130    ) -> Result<JournalEntry, String> {
131        let asserted = Self::now_unix() as u32;
132        let json = serde_json::to_string(credential).map_err(|e| e.to_string())?;
133        // Persist the full credential (incl. claims) as a content-addressed blob so a
134        // presentation can be built later; the envelope blob_hash is that content hash.
135        let hash = BlobStore::open(&self.storage_root)
136            .and_then(|store| store.put(json.as_bytes()))
137            .map_err(|e| e.to_string())?;
138        let envelope = build_credential_envelope(
139            credential,
140            &self.owner_did,
141            &self.author_did,
142            asserted,
143            Some(hash),
144        );
145        let summary = credential_summary(credential);
146        self.submit_record_with_summary(
147            QAPP_CREDENTIALS,
148            envelope,
149            SOURCE_CREDENTIALS,
150            Some(summary),
151        )?;
152        self.finalize_batch().ok();
153        self.latest_journal_entry()
154    }
155
156    pub fn list_credentials(&self, limit: usize) -> Result<Vec<JournalEntry>, String> {
157        self.list_journal_by_kind("credential", limit)
158    }
159
160    /// Load the full credential (including its claims) from its content-addressed blob.
161    /// Returns `None` if the record id is unknown or its blob is missing.
162    pub fn get_credential(&self, record_id: &str) -> Result<Option<CredentialRecord>, String> {
163        let Some(entry) = self
164            .list_credentials(256)?
165            .into_iter()
166            .find(|e| e.id == record_id)
167        else {
168            return Ok(None);
169        };
170        let Some(hash) = entry.blob_hash else {
171            return Ok(None);
172        };
173        let store = BlobStore::open(&self.storage_root).map_err(|e| e.to_string())?;
174        let Some(bytes) = store.get(&hash).map_err(|e| e.to_string())? else {
175            return Ok(None);
176        };
177        let cred: CredentialRecord = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
178        Ok(Some(cred))
179    }
180
181    /// Build a field-selected presentation of a stored credential — plain field selection, NOT
182    /// cryptographic selective disclosure (the type name and the domain module say so).
183    pub fn present_credential(
184        &self,
185        record_id: &str,
186        selected_claim_keys: &[String],
187    ) -> Result<FieldSelectedPresentation, String> {
188        let cred = self
189            .get_credential(record_id)?
190            .ok_or_else(|| format!("credential '{record_id}' not found or blob missing"))?;
191        Ok(build_presentation(&cred, selected_claim_keys))
192    }
193}