Skip to main content

difflore_core/team/
api.rs

1use openapi_contract::api;
2
3use crate::cloud::api_types::{
4    Extraction, InviteResult, Success, Team, TeamMember, TeamRuleSummary,
5};
6use crate::cloud::client::CloudClient;
7use crate::errors::CoreError;
8use crate::models::SkillRecord;
9
10use super::cloud_id::{
11    ensure_cloud_rule_id, resolve_cloud_rule_id_for_unpublish, resolve_existing_cloud_rule_id,
12};
13use super::types::{
14    ReviewInboxItem, TeamContextInput, TeamInviteInput, TeamInviteResult, TeamMemberIdInput,
15    TeamMemberRecord, TeamMembersResult, TeamRulePublishInput, TeamRuleUnpublishInput,
16    TeamSkillsResult, TeamUpdateRoleInput,
17};
18
19async fn resolve_team_id(
20    client: &CloudClient,
21    explicit: Option<String>,
22) -> crate::Result<(String, bool)> {
23    if let Some(id) = explicit {
24        return Ok((id, false));
25    }
26    let team: Option<Team> = api!(GET "/teams/my").fetch(client).await.ok().flatten();
27    match team {
28        Some(t) => Ok((t.id, true)),
29        // Reaching this branch means the cloud account isn't on a team yet.
30        // The previous bare "team" message bubbled up as `NotFound: team`
31        // — the user couldn't tell whether they typed something wrong, lost
32        // access, or just hadn't joined a team. Name the actual situation
33        // and point at the cloud surface that creates one.
34        None => Err(CoreError::NotFound(
35            "no team for the current cloud account. Create or join one at \
36             difflore.dev/team, then retry."
37                .into(),
38        )),
39    }
40}
41
42pub async fn members(input: TeamContextInput) -> crate::Result<TeamMembersResult> {
43    let client = CloudClient::create().await;
44    if !client.is_logged_in() {
45        return Ok(TeamMembersResult {
46            members: vec![],
47            default_team_used: false,
48        });
49    }
50    let (team_id, default_team_used) = resolve_team_id(&client, input.team_id).await?;
51    let members: Vec<TeamMember> = api!(GET "/teams/{id}/members", id = &team_id)
52        .fetch(&client)
53        .await?;
54    Ok(TeamMembersResult {
55        members: members.into_iter().map(TeamMemberRecord::from).collect(),
56        default_team_used,
57    })
58}
59
60pub async fn invite(input: TeamInviteInput) -> crate::Result<TeamInviteResult> {
61    let client = CloudClient::create().await;
62    if !client.is_logged_in() {
63        return Err(CoreError::Internal(
64            "not logged in to cloud. Run `difflore cloud login` first.".into(),
65        ));
66    }
67    let (team_id, default_team_used) = resolve_team_id(&client, input.team_id).await?;
68    let body = serde_json::json!({
69        "email": input.email,
70        "role": input.role.unwrap_or_else(|| "member".into()),
71    });
72    let result: InviteResult = api!(POST "/teams/{id}/invite", id = &team_id, body = &body)
73        .fetch(&client)
74        .await?;
75    Ok(TeamInviteResult {
76        id: result.id,
77        default_team_used,
78    })
79}
80
81pub async fn remove_member(input: TeamMemberIdInput) -> crate::Result<()> {
82    let client = CloudClient::create().await;
83    if !client.is_logged_in() {
84        return Err(CoreError::Internal(
85            "not logged in to cloud. Run `difflore cloud login` first.".into(),
86        ));
87    }
88    let (team_id, _) = resolve_team_id(&client, input.team_id).await?;
89    let _: Success =
90        api!(DELETE "/teams/{id}/members/{userId}", id = &team_id, userId = &input.user_id)
91            .fetch(&client)
92            .await?;
93    Ok(())
94}
95
96pub async fn update_role(input: TeamUpdateRoleInput) -> crate::Result<()> {
97    let client = CloudClient::create().await;
98    if !client.is_logged_in() {
99        return Err(CoreError::Internal(
100            "not logged in to cloud. Run `difflore cloud login` first.".into(),
101        ));
102    }
103    let (team_id, _) = resolve_team_id(&client, input.team_id).await?;
104    let body = serde_json::json!({ "role": input.role });
105    let _: Success = api!(PUT "/teams/{id}/members/{userId}/role", id = &team_id, userId = &input.user_id, body = &body)
106        .fetch(&client)
107        .await?;
108    Ok(())
109}
110
111pub async fn skills(input: TeamContextInput) -> crate::Result<TeamSkillsResult> {
112    let client = CloudClient::create().await;
113    if !client.is_logged_in() {
114        return Ok(TeamSkillsResult {
115            skills: vec![],
116            default_team_used: false,
117        });
118    }
119    let (_team_id, default_team_used) = resolve_team_id(&client, input.team_id).await?;
120    // /rules/team returns team rules for the current user (no team_id param needed)
121    let rules_json: Vec<serde_json::Value> = api!(GET "/rules/team").fetch(&client).await?;
122    let rules: Vec<TeamRuleSummary> = rules_json
123        .into_iter()
124        .map(serde_json::from_value)
125        .collect::<Result<_, _>>()?;
126    let skills = rules
127        .into_iter()
128        .map(|r| SkillRecord {
129            id: r.id,
130            name: r.name,
131            description: r.description,
132            r#type: r.r#type,
133            version: r.version,
134            engines: r.engines,
135            tags: r.tags,
136            trigger: r.trigger,
137            check_prompt: r.check_prompt,
138            directory: String::new(),
139            source: "team".into(),
140            repo_owner: None,
141            repo_name: None,
142            repo_branch: None,
143            readme_url: None,
144            enabled_for_codex: false,
145            enabled_for_claude: false,
146            enabled_for_gemini: false,
147            enabled_for_cursor: false,
148            installed_at: r.created_at.clone(),
149            updated_at: r.updated_at,
150            enforcement: r
151                .published_in_teams
152                .first()
153                .and_then(|entry| entry.get("enforcement"))
154                .and_then(|v| v.as_str())
155                .map(ToOwned::to_owned),
156            origin: "team".into(),
157        })
158        .collect();
159    Ok(TeamSkillsResult {
160        skills,
161        default_team_used,
162    })
163}
164
165/// Resolve a local/conversation rule id to an already-known cloud UUID.
166///
167/// This is intentionally read-only: unlike team publishing, it never creates a
168/// cloud rule row. Callers should treat `Ok(None)` as "do not attribute".
169pub async fn resolve_known_cloud_rule_id(
170    pool: &sqlx::SqlitePool,
171    rule_id: &str,
172) -> crate::Result<Option<String>> {
173    resolve_existing_cloud_rule_id(pool, rule_id).await
174}
175
176pub async fn publish_rule(input: TeamRulePublishInput) -> crate::Result<String> {
177    let client = CloudClient::create().await;
178    if !client.is_logged_in() {
179        return Err(CoreError::Internal(
180            "not logged in to cloud. Run `difflore cloud login` first.".into(),
181        ));
182    }
183
184    let (team_id, _) = resolve_team_id(&client, input.team_id).await?;
185    let pool = crate::db::init_db().await.map_err(CoreError::Internal)?;
186
187    if let Some(s) = crate::skills::rule_status(&pool, &input.rule_id).await?
188        && s == "pending"
189    {
190        return Err(CoreError::Validation(format!(
191            "rule '{}' is a pending memory draft. Run `difflore status` before publishing it to a team.",
192            input.rule_id,
193        )));
194    }
195
196    let cloud_rule_id = ensure_cloud_rule_id(&pool, &client, &input.rule_id).await?;
197
198    // Look up local origin when caller didn't pass one — saves the CLI
199    // and tests from threading it through every call site, while still
200    // letting the cloud layer make the publish-time provenance explicit.
201    // Read by the (possibly-rewritten) cloud uuid, which is the row's
202    // current id post-`ensure_cloud_rule_id`.
203    let origin = match input.origin {
204        Some(o) => Some(o),
205        None => sqlx::query_scalar!("SELECT origin FROM skills WHERE id = ?1", cloud_rule_id)
206            .fetch_optional(&pool)
207            .await
208            .ok()
209            .flatten(),
210    };
211    let body = serde_json::json!({
212        "ruleId": cloud_rule_id,
213        "teamId": team_id,
214        "enforcement": input.enforcement.unwrap_or_else(|| "recommended".into()),
215        "origin": origin,
216    });
217    let _: Success = api!(POST "/rules/team/publish", body = &body)
218        .fetch(&client)
219        .await?;
220    Ok(cloud_rule_id)
221}
222
223pub async fn unpublish_rule(input: TeamRuleUnpublishInput) -> crate::Result<()> {
224    let client = CloudClient::create().await;
225    if !client.is_logged_in() {
226        return Err(CoreError::Internal(
227            "not logged in to cloud. Run `difflore cloud login` first.".into(),
228        ));
229    }
230
231    let (team_id, _) = resolve_team_id(&client, input.team_id).await?;
232    let pool = crate::db::init_db().await.map_err(CoreError::Internal)?;
233    let cloud_rule_id = resolve_cloud_rule_id_for_unpublish(&pool, &input.rule_id).await?;
234    let body = serde_json::json!({
235        "ruleId": cloud_rule_id,
236        "teamId": team_id,
237    });
238    let _: Success = api!(POST "/rules/team/unpublish", body = &body)
239        .fetch(&client)
240        .await?;
241    Ok(())
242}
243
244pub async fn review_inbox(limit: usize) -> crate::Result<Vec<ReviewInboxItem>> {
245    let client = CloudClient::create().await;
246    if !client.is_logged_in() {
247        return Ok(vec![]);
248    }
249
250    let rows: Vec<Extraction> = api!(GET "/reviews/extractions/recent")
251        .fetch(&client)
252        .await?;
253
254    let items = rows
255        .into_iter()
256        .take(limit)
257        .map(|r| ReviewInboxItem {
258            id: r.id,
259            knowledge_type: r.knowledge_type,
260            title: r.title,
261            content: r.content,
262            confidence: r.confidence.unwrap_or(0.0),
263            status: r.status,
264            file_patterns: r.file_patterns.unwrap_or_default(),
265            created_at: r.created_at,
266        })
267        .collect();
268
269    Ok(items)
270}