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 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 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
165pub 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 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}