Skip to main content

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