koprogo_api/domain/entities/
skill.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Skill category for classification
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum SkillCategory {
8    /// Home repair and maintenance (plumbing, electrical, carpentry, etc.)
9    HomeRepair,
10    /// Languages (teaching, translation, conversation practice)
11    Languages,
12    /// Technology (IT support, web development, software, hardware)
13    Technology,
14    /// Education and tutoring (math, science, music lessons, etc.)
15    Education,
16    /// Arts and crafts (painting, sewing, woodworking, etc.)
17    Arts,
18    /// Sports and fitness (personal training, yoga, martial arts, etc.)
19    Sports,
20    /// Cooking and baking
21    Cooking,
22    /// Gardening and landscaping
23    Gardening,
24    /// Health and wellness (massage, physiotherapy, counseling, etc.)
25    Health,
26    /// Legal and administrative (tax preparation, document assistance, etc.)
27    Legal,
28    /// Financial (accounting, budgeting advice, etc.)
29    Financial,
30    /// Pet care and training
31    PetCare,
32    /// Other skills
33    Other,
34}
35
36/// Expertise level for skill proficiency
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
38pub enum ExpertiseLevel {
39    /// Beginner (< 1 year experience)
40    Beginner,
41    /// Intermediate (1-3 years experience)
42    Intermediate,
43    /// Advanced (3-10 years experience)
44    Advanced,
45    /// Expert (10+ years experience or professional certification)
46    Expert,
47}
48
49/// Skill profile for community members
50///
51/// Represents a skill that a building resident can offer to help other members.
52/// Integrates with SEL (Local Exchange Trading System) for optional credit-based compensation.
53///
54/// # Business Rules
55/// - skill_name must be 3-100 characters
56/// - description max 1000 characters
57/// - hourly_rate_credits: 0-100 (0 = free/volunteer, compatible with SEL system)
58/// - years_of_experience: 0-50
59/// - Only owner can update/delete their own skills
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Skill {
62    pub id: Uuid,
63    pub owner_id: Uuid,
64    pub building_id: Uuid,
65    pub skill_category: SkillCategory,
66    pub skill_name: String,
67    pub expertise_level: ExpertiseLevel,
68    pub description: String,
69    pub is_available_for_help: bool,
70    /// Hourly rate in SEL credits (0 = free/volunteer, None = not specified)
71    pub hourly_rate_credits: Option<i32>,
72    /// Years of experience (optional)
73    pub years_of_experience: Option<i32>,
74    /// Professional certifications or qualifications (optional)
75    pub certifications: Option<String>,
76    pub created_at: DateTime<Utc>,
77    pub updated_at: DateTime<Utc>,
78}
79
80impl Skill {
81    /// Create a new skill
82    ///
83    /// # Validation
84    /// - skill_name: 3-100 characters
85    /// - description: max 1000 characters
86    /// - hourly_rate_credits: 0-100 if provided
87    /// - years_of_experience: 0-50 if provided
88    pub fn new(
89        owner_id: Uuid,
90        building_id: Uuid,
91        skill_category: SkillCategory,
92        skill_name: String,
93        expertise_level: ExpertiseLevel,
94        description: String,
95        is_available_for_help: bool,
96        hourly_rate_credits: Option<i32>,
97        years_of_experience: Option<i32>,
98        certifications: Option<String>,
99    ) -> Result<Self, String> {
100        // Validate skill_name
101        if skill_name.len() < 3 {
102            return Err("Skill name must be at least 3 characters".to_string());
103        }
104        if skill_name.len() > 100 {
105            return Err("Skill name cannot exceed 100 characters".to_string());
106        }
107
108        // Validate description
109        if description.trim().is_empty() {
110            return Err("Description cannot be empty".to_string());
111        }
112        if description.len() > 1000 {
113            return Err("Description cannot exceed 1000 characters".to_string());
114        }
115
116        // Validate hourly_rate_credits (compatible with SEL: 0-100 credits)
117        if let Some(rate) = hourly_rate_credits {
118            if rate < 0 {
119                return Err("Hourly rate cannot be negative".to_string());
120            }
121            if rate > 100 {
122                return Err("Hourly rate cannot exceed 100 credits".to_string());
123            }
124        }
125
126        // Validate years_of_experience
127        if let Some(years) = years_of_experience {
128            if years < 0 {
129                return Err("Years of experience cannot be negative".to_string());
130            }
131            if years > 50 {
132                return Err("Years of experience cannot exceed 50".to_string());
133            }
134        }
135
136        let now = Utc::now();
137
138        Ok(Self {
139            id: Uuid::new_v4(),
140            owner_id,
141            building_id,
142            skill_category,
143            skill_name,
144            expertise_level,
145            description,
146            is_available_for_help,
147            hourly_rate_credits,
148            years_of_experience,
149            certifications,
150            created_at: now,
151            updated_at: now,
152        })
153    }
154
155    /// Update skill information
156    ///
157    /// # Validation
158    /// - Same validation rules as new()
159    pub fn update(
160        &mut self,
161        skill_name: Option<String>,
162        expertise_level: Option<ExpertiseLevel>,
163        description: Option<String>,
164        is_available_for_help: Option<bool>,
165        hourly_rate_credits: Option<Option<i32>>,
166        years_of_experience: Option<Option<i32>>,
167        certifications: Option<Option<String>>,
168    ) -> Result<(), String> {
169        // Update skill_name if provided
170        if let Some(name) = skill_name {
171            if name.len() < 3 {
172                return Err("Skill name must be at least 3 characters".to_string());
173            }
174            if name.len() > 100 {
175                return Err("Skill name cannot exceed 100 characters".to_string());
176            }
177            self.skill_name = name;
178        }
179
180        // Update expertise_level if provided
181        if let Some(level) = expertise_level {
182            self.expertise_level = level;
183        }
184
185        // Update description if provided
186        if let Some(desc) = description {
187            if desc.trim().is_empty() {
188                return Err("Description cannot be empty".to_string());
189            }
190            if desc.len() > 1000 {
191                return Err("Description cannot exceed 1000 characters".to_string());
192            }
193            self.description = desc;
194        }
195
196        // Update availability if provided
197        if let Some(available) = is_available_for_help {
198            self.is_available_for_help = available;
199        }
200
201        // Update hourly_rate_credits if provided
202        if let Some(rate_opt) = hourly_rate_credits {
203            if let Some(rate) = rate_opt {
204                if rate < 0 {
205                    return Err("Hourly rate cannot be negative".to_string());
206                }
207                if rate > 100 {
208                    return Err("Hourly rate cannot exceed 100 credits".to_string());
209                }
210            }
211            self.hourly_rate_credits = rate_opt;
212        }
213
214        // Update years_of_experience if provided
215        if let Some(years_opt) = years_of_experience {
216            if let Some(years) = years_opt {
217                if years < 0 {
218                    return Err("Years of experience cannot be negative".to_string());
219                }
220                if years > 50 {
221                    return Err("Years of experience cannot exceed 50".to_string());
222                }
223            }
224            self.years_of_experience = years_opt;
225        }
226
227        // Update certifications if provided
228        if let Some(cert_opt) = certifications {
229            self.certifications = cert_opt;
230        }
231
232        self.updated_at = Utc::now();
233        Ok(())
234    }
235
236    /// Mark skill as available for help
237    pub fn mark_available(&mut self) {
238        self.is_available_for_help = true;
239        self.updated_at = Utc::now();
240    }
241
242    /// Mark skill as unavailable for help
243    pub fn mark_unavailable(&mut self) {
244        self.is_available_for_help = false;
245        self.updated_at = Utc::now();
246    }
247
248    /// Check if skill is free (volunteer)
249    pub fn is_free(&self) -> bool {
250        self.hourly_rate_credits.is_none() || self.hourly_rate_credits == Some(0)
251    }
252
253    /// Check if skill is professional (has certifications or Expert level)
254    pub fn is_professional(&self) -> bool {
255        self.expertise_level == ExpertiseLevel::Expert || self.certifications.is_some()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_create_skill_success() {
265        let owner_id = Uuid::new_v4();
266        let building_id = Uuid::new_v4();
267
268        let skill = Skill::new(
269            owner_id,
270            building_id,
271            SkillCategory::Technology,
272            "Web Development".to_string(),
273            ExpertiseLevel::Advanced,
274            "Full-stack web development with React and Node.js".to_string(),
275            true,
276            Some(10), // 10 credits/hour
277            Some(5),  // 5 years experience
278            Some("Certified Web Developer".to_string()),
279        );
280
281        assert!(skill.is_ok());
282        let skill = skill.unwrap();
283        assert_eq!(skill.owner_id, owner_id);
284        assert_eq!(skill.building_id, building_id);
285        assert_eq!(skill.skill_category, SkillCategory::Technology);
286        assert!(skill.is_available_for_help);
287        assert_eq!(skill.hourly_rate_credits, Some(10));
288    }
289
290    #[test]
291    fn test_skill_name_too_short_fails() {
292        let owner_id = Uuid::new_v4();
293        let building_id = Uuid::new_v4();
294
295        let result = Skill::new(
296            owner_id,
297            building_id,
298            SkillCategory::HomeRepair,
299            "IT".to_string(), // Too short (< 3 chars)
300            ExpertiseLevel::Beginner,
301            "Basic IT support".to_string(),
302            true,
303            None,
304            None,
305            None,
306        );
307
308        assert!(result.is_err());
309        assert_eq!(
310            result.unwrap_err(),
311            "Skill name must be at least 3 characters"
312        );
313    }
314
315    #[test]
316    fn test_skill_name_too_long_fails() {
317        let owner_id = Uuid::new_v4();
318        let building_id = Uuid::new_v4();
319        let long_name = "A".repeat(101); // 101 chars (> 100)
320
321        let result = Skill::new(
322            owner_id,
323            building_id,
324            SkillCategory::HomeRepair,
325            long_name,
326            ExpertiseLevel::Beginner,
327            "Description".to_string(),
328            true,
329            None,
330            None,
331            None,
332        );
333
334        assert!(result.is_err());
335        assert_eq!(
336            result.unwrap_err(),
337            "Skill name cannot exceed 100 characters"
338        );
339    }
340
341    #[test]
342    fn test_empty_description_fails() {
343        let owner_id = Uuid::new_v4();
344        let building_id = Uuid::new_v4();
345
346        let result = Skill::new(
347            owner_id,
348            building_id,
349            SkillCategory::Cooking,
350            "Baking".to_string(),
351            ExpertiseLevel::Intermediate,
352            "   ".to_string(), // Empty/whitespace
353            true,
354            None,
355            None,
356            None,
357        );
358
359        assert!(result.is_err());
360        assert_eq!(result.unwrap_err(), "Description cannot be empty");
361    }
362
363    #[test]
364    fn test_hourly_rate_negative_fails() {
365        let owner_id = Uuid::new_v4();
366        let building_id = Uuid::new_v4();
367
368        let result = Skill::new(
369            owner_id,
370            building_id,
371            SkillCategory::Languages,
372            "English".to_string(),
373            ExpertiseLevel::Expert,
374            "English conversation and grammar".to_string(),
375            true,
376            Some(-5), // Negative rate
377            None,
378            None,
379        );
380
381        assert!(result.is_err());
382        assert_eq!(result.unwrap_err(), "Hourly rate cannot be negative");
383    }
384
385    #[test]
386    fn test_hourly_rate_exceeds_100_fails() {
387        let owner_id = Uuid::new_v4();
388        let building_id = Uuid::new_v4();
389
390        let result = Skill::new(
391            owner_id,
392            building_id,
393            SkillCategory::Legal,
394            "Tax Consulting".to_string(),
395            ExpertiseLevel::Expert,
396            "Professional tax preparation".to_string(),
397            true,
398            Some(150), // Exceeds 100 credits
399            None,
400            None,
401        );
402
403        assert!(result.is_err());
404        assert_eq!(result.unwrap_err(), "Hourly rate cannot exceed 100 credits");
405    }
406
407    #[test]
408    fn test_years_of_experience_negative_fails() {
409        let owner_id = Uuid::new_v4();
410        let building_id = Uuid::new_v4();
411
412        let result = Skill::new(
413            owner_id,
414            building_id,
415            SkillCategory::Sports,
416            "Yoga".to_string(),
417            ExpertiseLevel::Beginner,
418            "Hatha yoga for beginners".to_string(),
419            true,
420            None,
421            Some(-2), // Negative years
422            None,
423        );
424
425        assert!(result.is_err());
426        assert_eq!(
427            result.unwrap_err(),
428            "Years of experience cannot be negative"
429        );
430    }
431
432    #[test]
433    fn test_update_skill_success() {
434        let owner_id = Uuid::new_v4();
435        let building_id = Uuid::new_v4();
436
437        let mut skill = Skill::new(
438            owner_id,
439            building_id,
440            SkillCategory::Gardening,
441            "Gardening".to_string(),
442            ExpertiseLevel::Beginner,
443            "Basic gardening and plant care".to_string(),
444            true,
445            None,
446            Some(1),
447            None,
448        )
449        .unwrap();
450
451        let result = skill.update(
452            Some("Advanced Gardening".to_string()),
453            Some(ExpertiseLevel::Intermediate),
454            Some("Organic gardening and permaculture design".to_string()),
455            Some(true),
456            Some(Some(5)), // 5 credits/hour
457            Some(Some(3)), // 3 years experience
458            Some(Some("Permaculture Design Certificate".to_string())),
459        );
460
461        assert!(result.is_ok());
462        assert_eq!(skill.skill_name, "Advanced Gardening");
463        assert_eq!(skill.expertise_level, ExpertiseLevel::Intermediate);
464        assert_eq!(skill.hourly_rate_credits, Some(5));
465        assert_eq!(skill.years_of_experience, Some(3));
466    }
467
468    #[test]
469    fn test_mark_available() {
470        let owner_id = Uuid::new_v4();
471        let building_id = Uuid::new_v4();
472
473        let mut skill = Skill::new(
474            owner_id,
475            building_id,
476            SkillCategory::Arts,
477            "Painting".to_string(),
478            ExpertiseLevel::Advanced,
479            "Oil and watercolor painting".to_string(),
480            false, // Initially unavailable
481            None,
482            None,
483            None,
484        )
485        .unwrap();
486
487        assert!(!skill.is_available_for_help);
488
489        skill.mark_available();
490        assert!(skill.is_available_for_help);
491    }
492
493    #[test]
494    fn test_mark_unavailable() {
495        let owner_id = Uuid::new_v4();
496        let building_id = Uuid::new_v4();
497
498        let mut skill = Skill::new(
499            owner_id,
500            building_id,
501            SkillCategory::PetCare,
502            "Dog Training".to_string(),
503            ExpertiseLevel::Expert,
504            "Professional dog training and behavior modification".to_string(),
505            true, // Initially available
506            Some(15),
507            Some(10),
508            Some("Certified Dog Trainer".to_string()),
509        )
510        .unwrap();
511
512        assert!(skill.is_available_for_help);
513
514        skill.mark_unavailable();
515        assert!(!skill.is_available_for_help);
516    }
517
518    #[test]
519    fn test_is_free() {
520        let owner_id = Uuid::new_v4();
521        let building_id = Uuid::new_v4();
522
523        // Free skill (None)
524        let skill1 = Skill::new(
525            owner_id,
526            building_id,
527            SkillCategory::Education,
528            "Math Tutoring".to_string(),
529            ExpertiseLevel::Advanced,
530            "High school math tutoring".to_string(),
531            true,
532            None, // Free/volunteer
533            None,
534            None,
535        )
536        .unwrap();
537        assert!(skill1.is_free());
538
539        // Free skill (0 credits)
540        let skill2 = Skill::new(
541            owner_id,
542            building_id,
543            SkillCategory::Education,
544            "Math Tutoring".to_string(),
545            ExpertiseLevel::Advanced,
546            "High school math tutoring".to_string(),
547            true,
548            Some(0), // Explicitly 0 credits
549            None,
550            None,
551        )
552        .unwrap();
553        assert!(skill2.is_free());
554
555        // Paid skill
556        let skill3 = Skill::new(
557            owner_id,
558            building_id,
559            SkillCategory::Technology,
560            "IT Support".to_string(),
561            ExpertiseLevel::Expert,
562            "Professional IT support".to_string(),
563            true,
564            Some(20), // 20 credits/hour
565            None,
566            None,
567        )
568        .unwrap();
569        assert!(!skill3.is_free());
570    }
571
572    #[test]
573    fn test_is_professional() {
574        let owner_id = Uuid::new_v4();
575        let building_id = Uuid::new_v4();
576
577        // Professional (Expert level)
578        let skill1 = Skill::new(
579            owner_id,
580            building_id,
581            SkillCategory::Health,
582            "Massage Therapy".to_string(),
583            ExpertiseLevel::Expert,
584            "Therapeutic massage".to_string(),
585            true,
586            Some(30),
587            Some(15),
588            None,
589        )
590        .unwrap();
591        assert!(skill1.is_professional());
592
593        // Professional (has certifications)
594        let skill2 = Skill::new(
595            owner_id,
596            building_id,
597            SkillCategory::Financial,
598            "Accounting".to_string(),
599            ExpertiseLevel::Advanced,
600            "Small business accounting".to_string(),
601            true,
602            Some(25),
603            Some(5),
604            Some("CPA License".to_string()),
605        )
606        .unwrap();
607        assert!(skill2.is_professional());
608
609        // Not professional (Beginner, no certifications)
610        let skill3 = Skill::new(
611            owner_id,
612            building_id,
613            SkillCategory::Cooking,
614            "Baking".to_string(),
615            ExpertiseLevel::Beginner,
616            "Home baking enthusiast".to_string(),
617            true,
618            None,
619            Some(1),
620            None,
621        )
622        .unwrap();
623        assert!(!skill3.is_professional());
624    }
625}