1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sqlx::PgPool;
9use uuid::Uuid;
10
11use crate::error::ReputationError;
12
13#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
139pub struct PortabilityService {
141 pool: PgPool,
142}
143
144impl PortabilityService {
145 pub fn new(pool: PgPool) -> Self {
146 Self { pool }
147 }
148
149 pub async fn import_reputation(
151 &self,
152 request: ImportReputationRequest,
153 ) -> Result<ImportResult, ReputationError> {
154 let platform_str = request.platform.to_string();
155
156 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 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 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 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 pub async fn export_reputation(
218 &self,
219 request: ExportReputationRequest,
220 ) -> Result<VerifiableCredential, ReputationError> {
221 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 self.store_export_record(request.user_id, &credential)
283 .await?;
284
285 Ok(credential)
286 }
287
288 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 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 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 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 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 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 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 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 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}