kaccy_reputation/
portability.rs

1//! Reputation Portability for importing and exporting reputation data
2//!
3//! This module enables users to import reputation from external platforms
4//! (GitHub, LinkedIn, etc.) and export verifiable credentials for use elsewhere.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sqlx::PgPool;
9use uuid::Uuid;
10
11use crate::error::ReputationError;
12
13/// External reputation source
14#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
15pub struct ExternalReputation {
16    pub id: Uuid,
17    pub user_id: Uuid,
18    pub platform: String,
19    pub platform_user_id: String,
20    pub reputation_data: serde_json::Value,
21    pub imported_score: i32,
22    pub verified: bool,
23    pub verified_at: Option<DateTime<Utc>>,
24    pub created_at: DateTime<Utc>,
25    pub updated_at: DateTime<Utc>,
26}
27
28/// Platform type for reputation import
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum Platform {
32    GitHub,
33    LinkedIn,
34    StackOverflow,
35    Twitter,
36    Discord,
37    Custom(String),
38}
39
40/// GitHub reputation data
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GitHubReputation {
43    pub username: String,
44    pub followers: i32,
45    pub public_repos: i32,
46    pub total_stars: i32,
47    pub total_forks: i32,
48    pub contributions_last_year: i32,
49    pub account_age_days: i32,
50    pub top_languages: Vec<String>,
51}
52
53/// LinkedIn reputation data
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LinkedInReputation {
56    pub profile_id: String,
57    pub connections: i32,
58    pub endorsements: i32,
59    pub recommendations: i32,
60    pub years_experience: i32,
61    pub skills: Vec<String>,
62}
63
64/// Stack Overflow reputation data
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct StackOverflowReputation {
67    pub user_id: i32,
68    pub reputation: i32,
69    pub gold_badges: i32,
70    pub silver_badges: i32,
71    pub bronze_badges: i32,
72    pub answers: i32,
73    pub questions: i32,
74    pub top_tags: Vec<String>,
75}
76
77/// Verifiable credential for exporting reputation
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct VerifiableCredential {
80    #[serde(rename = "@context")]
81    pub context: Vec<String>,
82    #[serde(rename = "type")]
83    pub credential_type: Vec<String>,
84    pub id: String,
85    pub issuer: String,
86    pub issuance_date: DateTime<Utc>,
87    pub expiration_date: Option<DateTime<Utc>>,
88    pub credential_subject: CredentialSubject,
89    pub proof: CredentialProof,
90}
91
92/// Subject of the credential
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CredentialSubject {
95    pub id: String,
96    pub reputation_score: i32,
97    pub reputation_tier: String,
98    pub platform: String,
99    pub verified: bool,
100    pub as_of_date: DateTime<Utc>,
101}
102
103/// Cryptographic proof for the credential
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CredentialProof {
106    #[serde(rename = "type")]
107    pub proof_type: String,
108    pub created: DateTime<Utc>,
109    pub verification_method: String,
110    pub proof_value: String,
111}
112
113/// Request to import reputation
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ImportReputationRequest {
116    pub user_id: Uuid,
117    pub platform: Platform,
118    pub platform_user_id: String,
119    pub access_token: Option<String>,
120}
121
122/// Request to export reputation
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ExportReputationRequest {
125    pub user_id: Uuid,
126    pub include_proof: bool,
127    pub expiration_days: Option<i32>,
128}
129
130/// Import result
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ImportResult {
133    pub external_reputation_id: Uuid,
134    pub imported_score: i32,
135    pub verification_required: bool,
136    pub verification_url: Option<String>,
137}
138
139/// Service for reputation portability
140pub struct PortabilityService {
141    pool: PgPool,
142}
143
144impl PortabilityService {
145    pub fn new(pool: PgPool) -> Self {
146        Self { pool }
147    }
148
149    /// Import reputation from an external platform
150    pub async fn import_reputation(
151        &self,
152        request: ImportReputationRequest,
153    ) -> Result<ImportResult, ReputationError> {
154        let platform_str = request.platform.to_string();
155
156        // Fetch reputation data from platform
157        let reputation_data = self
158            .fetch_platform_data(
159                &request.platform,
160                &request.platform_user_id,
161                request.access_token,
162            )
163            .await?;
164
165        // Calculate imported score based on platform data
166        let imported_score = self.calculate_imported_score(&request.platform, &reputation_data);
167
168        let id = Uuid::new_v4();
169
170        let external_rep = sqlx::query_as::<_, ExternalReputation>(
171            r#"
172            INSERT INTO external_reputations (
173                id, user_id, platform, platform_user_id, reputation_data,
174                imported_score, verified, created_at, updated_at
175            ) VALUES ($1, $2, $3, $4, $5, $6, false, NOW(), NOW())
176            RETURNING *
177            "#,
178        )
179        .bind(id)
180        .bind(request.user_id)
181        .bind(&platform_str)
182        .bind(&request.platform_user_id)
183        .bind(&reputation_data)
184        .bind(imported_score)
185        .fetch_one(&self.pool)
186        .await?;
187
188        // Generate verification URL
189        let verification_url =
190            format!("https://api.kaccy.io/reputation/verify/{}", external_rep.id);
191
192        Ok(ImportResult {
193            external_reputation_id: external_rep.id,
194            imported_score,
195            verification_required: true,
196            verification_url: Some(verification_url),
197        })
198    }
199
200    /// Verify an imported reputation
201    pub async fn verify_import(&self, external_rep_id: Uuid) -> Result<(), ReputationError> {
202        sqlx::query(
203            r#"
204            UPDATE external_reputations
205            SET verified = true, verified_at = NOW(), updated_at = NOW()
206            WHERE id = $1
207            "#,
208        )
209        .bind(external_rep_id)
210        .execute(&self.pool)
211        .await?;
212
213        Ok(())
214    }
215
216    /// Export reputation as a verifiable credential
217    pub async fn export_reputation(
218        &self,
219        request: ExportReputationRequest,
220    ) -> Result<VerifiableCredential, ReputationError> {
221        // Get user's current reputation
222        let score = sqlx::query_as::<_, (i32, String)>(
223            r#"
224            SELECT total_score,
225                   CASE
226                       WHEN total_score >= 800 THEN 'Platinum'
227                       WHEN total_score >= 600 THEN 'Gold'
228                       WHEN total_score >= 400 THEN 'Silver'
229                       WHEN total_score >= 200 THEN 'Bronze'
230                       ELSE 'Restricted'
231                   END as tier
232            FROM reputation_scores
233            WHERE user_id = $1
234            "#,
235        )
236        .bind(request.user_id)
237        .fetch_one(&self.pool)
238        .await?;
239
240        let (total_score, tier) = score;
241
242        let credential_id = Uuid::new_v4();
243        let now = Utc::now();
244        let expiration = request
245            .expiration_days
246            .map(|days| now + chrono::Duration::days(days as i64));
247
248        let credential = VerifiableCredential {
249            context: vec![
250                "https://www.w3.org/2018/credentials/v1".to_string(),
251                "https://kaccy.io/credentials/v1".to_string(),
252            ],
253            credential_type: vec![
254                "VerifiableCredential".to_string(),
255                "ReputationCredential".to_string(),
256            ],
257            id: format!("https://kaccy.io/credentials/{}", credential_id),
258            issuer: "did:kaccy:issuer".to_string(),
259            issuance_date: now,
260            expiration_date: expiration,
261            credential_subject: CredentialSubject {
262                id: format!("did:kaccy:user:{}", request.user_id),
263                reputation_score: total_score,
264                reputation_tier: tier,
265                platform: "Kaccy Protocol".to_string(),
266                verified: true,
267                as_of_date: now,
268            },
269            proof: if request.include_proof {
270                self.generate_proof(&credential_id, &request.user_id, total_score, now)
271            } else {
272                CredentialProof {
273                    proof_type: "Ed25519Signature2020".to_string(),
274                    created: now,
275                    verification_method: "https://kaccy.io/keys/1".to_string(),
276                    proof_value: "placeholder".to_string(),
277                }
278            },
279        };
280
281        // Store export record
282        self.store_export_record(request.user_id, &credential)
283            .await?;
284
285        Ok(credential)
286    }
287
288    /// Get all imported reputations for a user
289    pub async fn get_imports(
290        &self,
291        user_id: Uuid,
292    ) -> Result<Vec<ExternalReputation>, ReputationError> {
293        let imports = sqlx::query_as::<_, ExternalReputation>(
294            "SELECT * FROM external_reputations WHERE user_id = $1 ORDER BY created_at DESC",
295        )
296        .bind(user_id)
297        .fetch_all(&self.pool)
298        .await?;
299
300        Ok(imports)
301    }
302
303    /// Get verified imports only
304    pub async fn get_verified_imports(
305        &self,
306        user_id: Uuid,
307    ) -> Result<Vec<ExternalReputation>, ReputationError> {
308        let imports = sqlx::query_as::<_, ExternalReputation>(
309            "SELECT * FROM external_reputations WHERE user_id = $1 AND verified = true ORDER BY created_at DESC",
310        )
311        .bind(user_id)
312        .fetch_all(&self.pool)
313        .await?;
314
315        Ok(imports)
316    }
317
318    /// Calculate total imported score for a user
319    pub async fn calculate_total_imported_score(
320        &self,
321        user_id: Uuid,
322    ) -> Result<i32, ReputationError> {
323        let total = sqlx::query_scalar::<_, Option<i64>>(
324            "SELECT SUM(imported_score) FROM external_reputations WHERE user_id = $1 AND verified = true",
325        )
326        .bind(user_id)
327        .fetch_one(&self.pool)
328        .await?;
329
330        Ok(total.unwrap_or(0) as i32)
331    }
332
333    // Helper methods
334
335    async fn fetch_platform_data(
336        &self,
337        platform: &Platform,
338        _platform_user_id: &str,
339        _access_token: Option<String>,
340    ) -> Result<serde_json::Value, ReputationError> {
341        // In a real implementation, this would call external APIs
342        // For now, return placeholder data
343        match platform {
344            Platform::GitHub => Ok(serde_json::json!({
345                "username": "example",
346                "followers": 100,
347                "public_repos": 50,
348                "total_stars": 500,
349                "total_forks": 100,
350                "contributions_last_year": 1000,
351                "account_age_days": 1000,
352                "top_languages": ["Rust", "TypeScript"]
353            })),
354            Platform::LinkedIn => Ok(serde_json::json!({
355                "profile_id": "example",
356                "connections": 500,
357                "endorsements": 50,
358                "recommendations": 10,
359                "years_experience": 5,
360                "skills": ["Software Development", "Leadership"]
361            })),
362            Platform::StackOverflow => Ok(serde_json::json!({
363                "user_id": 12345,
364                "reputation": 5000,
365                "gold_badges": 2,
366                "silver_badges": 10,
367                "bronze_badges": 50,
368                "answers": 200,
369                "questions": 50,
370                "top_tags": ["rust", "typescript", "javascript"]
371            })),
372            _ => Ok(serde_json::json!({})),
373        }
374    }
375
376    fn calculate_imported_score(&self, platform: &Platform, data: &serde_json::Value) -> i32 {
377        match platform {
378            Platform::GitHub => {
379                if let Ok(github) = serde_json::from_value::<GitHubReputation>(data.clone()) {
380                    // Simple scoring formula
381                    let stars_score = (github.total_stars / 10).min(100);
382                    let repos_score = (github.public_repos * 2).min(50);
383                    let contrib_score = (github.contributions_last_year / 10).min(100);
384                    let follower_score = (github.followers / 2).min(50);
385                    stars_score + repos_score + contrib_score + follower_score
386                } else {
387                    0
388                }
389            }
390            Platform::LinkedIn => {
391                if let Ok(linkedin) = serde_json::from_value::<LinkedInReputation>(data.clone()) {
392                    let connection_score = (linkedin.connections / 10).min(50);
393                    let endorsement_score = (linkedin.endorsements * 2).min(100);
394                    let rec_score = (linkedin.recommendations * 5).min(50);
395                    let exp_score = (linkedin.years_experience * 10).min(100);
396                    connection_score + endorsement_score + rec_score + exp_score
397                } else {
398                    0
399                }
400            }
401            Platform::StackOverflow => {
402                if let Ok(so) = serde_json::from_value::<StackOverflowReputation>(data.clone()) {
403                    (so.reputation / 10).min(300)
404                } else {
405                    0
406                }
407            }
408            _ => 0,
409        }
410    }
411
412    fn generate_proof(
413        &self,
414        _credential_id: &Uuid,
415        _user_id: &Uuid,
416        _score: i32,
417        created: DateTime<Utc>,
418    ) -> CredentialProof {
419        // In a real implementation, this would use cryptographic signing
420        CredentialProof {
421            proof_type: "Ed25519Signature2020".to_string(),
422            created,
423            verification_method: "https://kaccy.io/keys/1".to_string(),
424            proof_value: "simulated_signature_placeholder".to_string(),
425        }
426    }
427
428    async fn store_export_record(
429        &self,
430        user_id: Uuid,
431        credential: &VerifiableCredential,
432    ) -> Result<(), ReputationError> {
433        let credential_json = serde_json::to_value(credential).map_err(|e| {
434            ReputationError::Validation(format!("Failed to serialize credential: {}", e))
435        })?;
436
437        sqlx::query(
438            r#"
439            INSERT INTO reputation_exports (
440                id, user_id, credential_data, exported_at
441            ) VALUES ($1, $2, $3, NOW())
442            "#,
443        )
444        .bind(Uuid::new_v4())
445        .bind(user_id)
446        .bind(credential_json)
447        .execute(&self.pool)
448        .await?;
449
450        Ok(())
451    }
452}
453
454impl std::fmt::Display for Platform {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        match self {
457            Platform::GitHub => write!(f, "github"),
458            Platform::LinkedIn => write!(f, "linkedin"),
459            Platform::StackOverflow => write!(f, "stackoverflow"),
460            Platform::Twitter => write!(f, "twitter"),
461            Platform::Discord => write!(f, "discord"),
462            Platform::Custom(name) => write!(f, "{}", name),
463        }
464    }
465}
466
467impl std::str::FromStr for Platform {
468    type Err = ReputationError;
469
470    fn from_str(s: &str) -> Result<Self, Self::Err> {
471        match s.to_lowercase().as_str() {
472            "github" => Ok(Platform::GitHub),
473            "linkedin" => Ok(Platform::LinkedIn),
474            "stackoverflow" | "stack_overflow" | "stack overflow" => Ok(Platform::StackOverflow),
475            "twitter" => Ok(Platform::Twitter),
476            "discord" => Ok(Platform::Discord),
477            custom => Ok(Platform::Custom(custom.to_string())),
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_platform_display() {
488        assert_eq!(Platform::GitHub.to_string(), "github");
489        assert_eq!(Platform::LinkedIn.to_string(), "linkedin");
490        assert_eq!(Platform::StackOverflow.to_string(), "stackoverflow");
491        assert_eq!(Platform::Custom("custom".to_string()).to_string(), "custom");
492    }
493
494    #[test]
495    fn test_platform_from_str_valid() {
496        assert_eq!("github".parse::<Platform>().unwrap(), Platform::GitHub);
497        assert_eq!("linkedin".parse::<Platform>().unwrap(), Platform::LinkedIn);
498        assert_eq!(
499            "stackoverflow".parse::<Platform>().unwrap(),
500            Platform::StackOverflow
501        );
502        assert_eq!("twitter".parse::<Platform>().unwrap(), Platform::Twitter);
503        assert_eq!("discord".parse::<Platform>().unwrap(), Platform::Discord);
504    }
505
506    #[test]
507    fn test_platform_from_str_variants() {
508        // Test stack overflow variants
509        assert_eq!(
510            "stack_overflow".parse::<Platform>().unwrap(),
511            Platform::StackOverflow
512        );
513        assert_eq!(
514            "stack overflow".parse::<Platform>().unwrap(),
515            Platform::StackOverflow
516        );
517    }
518
519    #[test]
520    fn test_platform_from_str_case_insensitive() {
521        assert_eq!("GITHUB".parse::<Platform>().unwrap(), Platform::GitHub);
522        assert_eq!("LinkedIn".parse::<Platform>().unwrap(), Platform::LinkedIn);
523        assert_eq!(
524            "STACKOVERFLOW".parse::<Platform>().unwrap(),
525            Platform::StackOverflow
526        );
527    }
528
529    #[test]
530    fn test_platform_from_str_custom() {
531        // Unknown platforms should become Custom
532        assert_eq!(
533            "myplatform".parse::<Platform>().unwrap(),
534            Platform::Custom("myplatform".to_string())
535        );
536        assert_eq!(
537            "reddit".parse::<Platform>().unwrap(),
538            Platform::Custom("reddit".to_string())
539        );
540    }
541}