koprogo_api/domain/entities/
shared_object.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Category for shared objects
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum SharedObjectCategory {
8    /// Tools and equipment (drill, ladder, hammer, saw, etc.)
9    Tools,
10    /// Books and magazines
11    Books,
12    /// Electronics (projector, camera, tablet, etc.)
13    Electronics,
14    /// Sports equipment (bike, skis, tennis racket, etc.)
15    Sports,
16    /// Gardening tools and equipment (mower, trimmer, etc.)
17    Gardening,
18    /// Kitchen appliances (mixer, pressure cooker, etc.)
19    Kitchen,
20    /// Baby and children items (stroller, car seat, toys, etc.)
21    Baby,
22    /// Other shared objects
23    Other,
24}
25
26/// Condition of shared object
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
28pub enum ObjectCondition {
29    /// Excellent condition (like new)
30    Excellent,
31    /// Good condition (minor wear)
32    Good,
33    /// Fair condition (visible wear but functional)
34    Fair,
35    /// Used condition (significant wear)
36    Used,
37}
38
39/// Shared object for community equipment sharing
40///
41/// Represents an object that a building resident can lend to other members.
42/// Integrates with SEL (Local Exchange Trading System) for optional credit-based rental.
43///
44/// # Business Rules
45/// - object_name must be 3-100 characters
46/// - description max 1000 characters
47/// - rental_credits_per_day: 0-20 (0 = free, reasonable daily rate)
48/// - deposit_credits: 0-100 (security deposit)
49/// - borrowing_duration_days: 1-90 (max borrowing period)
50/// - Only owner can update/delete object
51/// - Only available objects can be borrowed
52/// - Borrower must return object before borrowing another from same owner
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SharedObject {
55    pub id: Uuid,
56    pub owner_id: Uuid,
57    pub building_id: Uuid,
58    pub object_category: SharedObjectCategory,
59    pub object_name: String,
60    pub description: String,
61    pub condition: ObjectCondition,
62    pub is_available: bool,
63    /// Rental rate in SEL credits per day (0 = free, None = not specified)
64    pub rental_credits_per_day: Option<i32>,
65    /// Security deposit in SEL credits (refunded on return)
66    pub deposit_credits: Option<i32>,
67    /// Maximum borrowing duration in days (1-90)
68    pub borrowing_duration_days: Option<i32>,
69    /// Current borrower (if borrowed)
70    pub current_borrower_id: Option<Uuid>,
71    /// When object was borrowed
72    pub borrowed_at: Option<DateTime<Utc>>,
73    /// When object is due back
74    pub due_back_at: Option<DateTime<Utc>>,
75    /// Photo URLs (optional)
76    pub photos: Option<Vec<String>>,
77    /// Pickup location details (optional)
78    pub location_details: Option<String>,
79    /// Usage instructions (optional)
80    pub usage_instructions: Option<String>,
81    pub created_at: DateTime<Utc>,
82    pub updated_at: DateTime<Utc>,
83}
84
85impl SharedObject {
86    /// Create a new shared object
87    ///
88    /// # Validation
89    /// - object_name: 3-100 characters
90    /// - description: max 1000 characters
91    /// - rental_credits_per_day: 0-20 if provided
92    /// - deposit_credits: 0-100 if provided
93    /// - borrowing_duration_days: 1-90 if provided
94    #[allow(clippy::too_many_arguments)]
95    pub fn new(
96        owner_id: Uuid,
97        building_id: Uuid,
98        object_category: SharedObjectCategory,
99        object_name: String,
100        description: String,
101        condition: ObjectCondition,
102        is_available: bool,
103        rental_credits_per_day: Option<i32>,
104        deposit_credits: Option<i32>,
105        borrowing_duration_days: Option<i32>,
106        photos: Option<Vec<String>>,
107        location_details: Option<String>,
108        usage_instructions: Option<String>,
109    ) -> Result<Self, String> {
110        // Validate object_name
111        if object_name.len() < 3 {
112            return Err("Object name must be at least 3 characters".to_string());
113        }
114        if object_name.len() > 100 {
115            return Err("Object name cannot exceed 100 characters".to_string());
116        }
117
118        // Validate description
119        if description.trim().is_empty() {
120            return Err("Description cannot be empty".to_string());
121        }
122        if description.len() > 1000 {
123            return Err("Description cannot exceed 1000 characters".to_string());
124        }
125
126        // Validate rental_credits_per_day (0-20 credits/day)
127        if let Some(rate) = rental_credits_per_day {
128            if rate < 0 {
129                return Err("Rental rate cannot be negative".to_string());
130            }
131            if rate > 20 {
132                return Err("Rental rate cannot exceed 20 credits per day".to_string());
133            }
134        }
135
136        // Validate deposit_credits (0-100 credits)
137        if let Some(deposit) = deposit_credits {
138            if deposit < 0 {
139                return Err("Deposit cannot be negative".to_string());
140            }
141            if deposit > 100 {
142                return Err("Deposit cannot exceed 100 credits".to_string());
143            }
144        }
145
146        // Validate borrowing_duration_days (1-90 days)
147        if let Some(duration) = borrowing_duration_days {
148            if duration < 1 {
149                return Err("Borrowing duration must be at least 1 day".to_string());
150            }
151            if duration > 90 {
152                return Err("Borrowing duration cannot exceed 90 days".to_string());
153            }
154        }
155
156        let now = Utc::now();
157
158        Ok(Self {
159            id: Uuid::new_v4(),
160            owner_id,
161            building_id,
162            object_category,
163            object_name,
164            description,
165            condition,
166            is_available,
167            rental_credits_per_day,
168            deposit_credits,
169            borrowing_duration_days,
170            current_borrower_id: None,
171            borrowed_at: None,
172            due_back_at: None,
173            photos,
174            location_details,
175            usage_instructions,
176            created_at: now,
177            updated_at: now,
178        })
179    }
180
181    /// Update shared object information
182    ///
183    /// # Validation
184    /// - Same validation rules as new()
185    /// - Cannot update if currently borrowed
186    #[allow(clippy::too_many_arguments)]
187    pub fn update(
188        &mut self,
189        object_name: Option<String>,
190        description: Option<String>,
191        condition: Option<ObjectCondition>,
192        is_available: Option<bool>,
193        rental_credits_per_day: Option<Option<i32>>,
194        deposit_credits: Option<Option<i32>>,
195        borrowing_duration_days: Option<Option<i32>>,
196        photos: Option<Option<Vec<String>>>,
197        location_details: Option<Option<String>>,
198        usage_instructions: Option<Option<String>>,
199    ) -> Result<(), String> {
200        // Cannot update if currently borrowed
201        if self.is_borrowed() {
202            return Err("Cannot update object while it is borrowed".to_string());
203        }
204
205        // Update object_name if provided
206        if let Some(name) = object_name {
207            if name.len() < 3 {
208                return Err("Object name must be at least 3 characters".to_string());
209            }
210            if name.len() > 100 {
211                return Err("Object name cannot exceed 100 characters".to_string());
212            }
213            self.object_name = name;
214        }
215
216        // Update description if provided
217        if let Some(desc) = description {
218            if desc.trim().is_empty() {
219                return Err("Description cannot be empty".to_string());
220            }
221            if desc.len() > 1000 {
222                return Err("Description cannot exceed 1000 characters".to_string());
223            }
224            self.description = desc;
225        }
226
227        // Update condition if provided
228        if let Some(cond) = condition {
229            self.condition = cond;
230        }
231
232        // Update availability if provided
233        if let Some(available) = is_available {
234            self.is_available = available;
235        }
236
237        // Update rental_credits_per_day if provided
238        if let Some(rate_opt) = rental_credits_per_day {
239            if let Some(rate) = rate_opt {
240                if rate < 0 {
241                    return Err("Rental rate cannot be negative".to_string());
242                }
243                if rate > 20 {
244                    return Err("Rental rate cannot exceed 20 credits per day".to_string());
245                }
246            }
247            self.rental_credits_per_day = rate_opt;
248        }
249
250        // Update deposit_credits if provided
251        if let Some(deposit_opt) = deposit_credits {
252            if let Some(deposit) = deposit_opt {
253                if deposit < 0 {
254                    return Err("Deposit cannot be negative".to_string());
255                }
256                if deposit > 100 {
257                    return Err("Deposit cannot exceed 100 credits".to_string());
258                }
259            }
260            self.deposit_credits = deposit_opt;
261        }
262
263        // Update borrowing_duration_days if provided
264        if let Some(duration_opt) = borrowing_duration_days {
265            if let Some(duration) = duration_opt {
266                if duration < 1 {
267                    return Err("Borrowing duration must be at least 1 day".to_string());
268                }
269                if duration > 90 {
270                    return Err("Borrowing duration cannot exceed 90 days".to_string());
271                }
272            }
273            self.borrowing_duration_days = duration_opt;
274        }
275
276        // Update photos if provided
277        if let Some(photos_opt) = photos {
278            self.photos = photos_opt;
279        }
280
281        // Update location_details if provided
282        if let Some(location_opt) = location_details {
283            self.location_details = location_opt;
284        }
285
286        // Update usage_instructions if provided
287        if let Some(instructions_opt) = usage_instructions {
288            self.usage_instructions = instructions_opt;
289        }
290
291        self.updated_at = Utc::now();
292        Ok(())
293    }
294
295    /// Mark object as available for borrowing
296    pub fn mark_available(&mut self) -> Result<(), String> {
297        if self.is_borrowed() {
298            return Err("Cannot mark as available while borrowed".to_string());
299        }
300        self.is_available = true;
301        self.updated_at = Utc::now();
302        Ok(())
303    }
304
305    /// Mark object as unavailable for borrowing
306    pub fn mark_unavailable(&mut self) {
307        self.is_available = false;
308        self.updated_at = Utc::now();
309    }
310
311    /// Borrow object
312    ///
313    /// # Validation
314    /// - Object must be available
315    /// - Borrower cannot be the owner
316    pub fn borrow(&mut self, borrower_id: Uuid, duration_days: Option<i32>) -> Result<(), String> {
317        if !self.is_available {
318            return Err("Object is not available for borrowing".to_string());
319        }
320
321        if self.is_borrowed() {
322            return Err("Object is already borrowed".to_string());
323        }
324
325        if borrower_id == self.owner_id {
326            return Err("Owner cannot borrow their own object".to_string());
327        }
328
329        let duration = duration_days.or(self.borrowing_duration_days).unwrap_or(7); // Default 7 days
330
331        if duration < 1 || duration > 90 {
332            return Err("Borrowing duration must be between 1 and 90 days".to_string());
333        }
334
335        let now = Utc::now();
336        let due_back = now + Duration::days(duration as i64);
337
338        self.current_borrower_id = Some(borrower_id);
339        self.borrowed_at = Some(now);
340        self.due_back_at = Some(due_back);
341        self.is_available = false;
342        self.updated_at = now;
343
344        Ok(())
345    }
346
347    /// Return borrowed object
348    ///
349    /// # Validation
350    /// - Object must be borrowed
351    /// - Only borrower can return
352    pub fn return_object(&mut self, returner_id: Uuid) -> Result<(), String> {
353        if !self.is_borrowed() {
354            return Err("Object is not currently borrowed".to_string());
355        }
356
357        if self.current_borrower_id != Some(returner_id) {
358            return Err("Only borrower can return object".to_string());
359        }
360
361        self.current_borrower_id = None;
362        self.borrowed_at = None;
363        self.due_back_at = None;
364        self.is_available = true;
365        self.updated_at = Utc::now();
366
367        Ok(())
368    }
369
370    /// Check if object is currently borrowed
371    pub fn is_borrowed(&self) -> bool {
372        self.current_borrower_id.is_some()
373    }
374
375    /// Check if object is free (no rental fee)
376    pub fn is_free(&self) -> bool {
377        self.rental_credits_per_day.is_none() || self.rental_credits_per_day == Some(0)
378    }
379
380    /// Check if object is overdue
381    pub fn is_overdue(&self) -> bool {
382        if let Some(due_back) = self.due_back_at {
383            Utc::now() > due_back
384        } else {
385            false
386        }
387    }
388
389    /// Calculate total rental cost for actual borrowing period
390    ///
391    /// Returns (rental_cost, deposit)
392    pub fn calculate_total_cost(&self) -> (i32, i32) {
393        let rental_cost =
394            if let (Some(borrowed), Some(rate)) = (self.borrowed_at, self.rental_credits_per_day) {
395                let days_borrowed = (Utc::now() - borrowed).num_days() + 1; // At least 1 day
396                (days_borrowed as i32) * rate
397            } else {
398                0
399            };
400
401        let deposit = self.deposit_credits.unwrap_or(0);
402
403        (rental_cost, deposit)
404    }
405
406    /// Calculate days overdue
407    pub fn days_overdue(&self) -> i32 {
408        if let Some(due_back) = self.due_back_at {
409            let overdue_duration = Utc::now() - due_back;
410            if overdue_duration.num_days() > 0 {
411                return overdue_duration.num_days() as i32;
412            }
413        }
414        0
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_create_shared_object_success() {
424        let owner_id = Uuid::new_v4();
425        let building_id = Uuid::new_v4();
426
427        let object = SharedObject::new(
428            owner_id,
429            building_id,
430            SharedObjectCategory::Tools,
431            "Power Drill".to_string(),
432            "18V cordless drill with battery".to_string(),
433            ObjectCondition::Good,
434            true,
435            Some(2),  // 2 credits/day
436            Some(10), // 10 credits deposit
437            Some(7),  // Max 7 days
438            None,
439            Some("Basement storage room".to_string()),
440            Some("Charge battery before use".to_string()),
441        );
442
443        assert!(object.is_ok());
444        let object = object.unwrap();
445        assert_eq!(object.owner_id, owner_id);
446        assert_eq!(object.object_category, SharedObjectCategory::Tools);
447        assert!(object.is_available);
448        assert!(!object.is_free());
449        assert!(!object.is_borrowed());
450    }
451
452    #[test]
453    fn test_object_name_too_short_fails() {
454        let owner_id = Uuid::new_v4();
455        let building_id = Uuid::new_v4();
456
457        let result = SharedObject::new(
458            owner_id,
459            building_id,
460            SharedObjectCategory::Books,
461            "AB".to_string(), // Too short (< 3 chars)
462            "Description".to_string(),
463            ObjectCondition::Excellent,
464            true,
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471        );
472
473        assert!(result.is_err());
474        assert_eq!(
475            result.unwrap_err(),
476            "Object name must be at least 3 characters"
477        );
478    }
479
480    #[test]
481    fn test_rental_rate_exceeds_20_fails() {
482        let owner_id = Uuid::new_v4();
483        let building_id = Uuid::new_v4();
484
485        let result = SharedObject::new(
486            owner_id,
487            building_id,
488            SharedObjectCategory::Electronics,
489            "Projector".to_string(),
490            "HD projector".to_string(),
491            ObjectCondition::Excellent,
492            true,
493            Some(25), // Exceeds 20 credits/day
494            None,
495            None,
496            None,
497            None,
498            None,
499        );
500
501        assert!(result.is_err());
502        assert_eq!(
503            result.unwrap_err(),
504            "Rental rate cannot exceed 20 credits per day"
505        );
506    }
507
508    #[test]
509    fn test_borrow_object_success() {
510        let owner_id = Uuid::new_v4();
511        let building_id = Uuid::new_v4();
512        let borrower_id = Uuid::new_v4();
513
514        let mut object = SharedObject::new(
515            owner_id,
516            building_id,
517            SharedObjectCategory::Sports,
518            "Mountain Bike".to_string(),
519            "26 inch mountain bike".to_string(),
520            ObjectCondition::Good,
521            true,
522            Some(5),
523            Some(20),
524            Some(3), // Max 3 days
525            None,
526            None,
527            None,
528        )
529        .unwrap();
530
531        let result = object.borrow(borrower_id, Some(3));
532        assert!(result.is_ok());
533        assert!(object.is_borrowed());
534        assert!(!object.is_available);
535        assert_eq!(object.current_borrower_id, Some(borrower_id));
536        assert!(object.borrowed_at.is_some());
537        assert!(object.due_back_at.is_some());
538    }
539
540    #[test]
541    fn test_owner_cannot_borrow_own_object() {
542        let owner_id = Uuid::new_v4();
543        let building_id = Uuid::new_v4();
544
545        let mut object = SharedObject::new(
546            owner_id,
547            building_id,
548            SharedObjectCategory::Gardening,
549            "Lawn Mower".to_string(),
550            "Electric lawn mower".to_string(),
551            ObjectCondition::Excellent,
552            true,
553            Some(3),
554            None,
555            Some(1),
556            None,
557            None,
558            None,
559        )
560        .unwrap();
561
562        let result = object.borrow(owner_id, Some(1)); // Owner tries to borrow
563        assert!(result.is_err());
564        assert_eq!(result.unwrap_err(), "Owner cannot borrow their own object");
565    }
566
567    #[test]
568    fn test_return_object_success() {
569        let owner_id = Uuid::new_v4();
570        let building_id = Uuid::new_v4();
571        let borrower_id = Uuid::new_v4();
572
573        let mut object = SharedObject::new(
574            owner_id,
575            building_id,
576            SharedObjectCategory::Kitchen,
577            "Mixer".to_string(),
578            "Stand mixer".to_string(),
579            ObjectCondition::Good,
580            true,
581            None, // Free
582            None,
583            Some(7),
584            None,
585            None,
586            None,
587        )
588        .unwrap();
589
590        object.borrow(borrower_id, Some(7)).unwrap();
591        assert!(object.is_borrowed());
592
593        let result = object.return_object(borrower_id);
594        assert!(result.is_ok());
595        assert!(!object.is_borrowed());
596        assert!(object.is_available);
597        assert_eq!(object.current_borrower_id, None);
598    }
599
600    #[test]
601    fn test_only_borrower_can_return() {
602        let owner_id = Uuid::new_v4();
603        let building_id = Uuid::new_v4();
604        let borrower_id = Uuid::new_v4();
605        let other_user_id = Uuid::new_v4();
606
607        let mut object = SharedObject::new(
608            owner_id,
609            building_id,
610            SharedObjectCategory::Baby,
611            "Stroller".to_string(),
612            "Baby stroller".to_string(),
613            ObjectCondition::Fair,
614            true,
615            None,
616            None,
617            Some(14),
618            None,
619            None,
620            None,
621        )
622        .unwrap();
623
624        object.borrow(borrower_id, Some(14)).unwrap();
625
626        let result = object.return_object(other_user_id); // Wrong user
627        assert!(result.is_err());
628        assert_eq!(result.unwrap_err(), "Only borrower can return object");
629    }
630
631    #[test]
632    fn test_is_free() {
633        let owner_id = Uuid::new_v4();
634        let building_id = Uuid::new_v4();
635
636        // Free (None)
637        let object1 = SharedObject::new(
638            owner_id,
639            building_id,
640            SharedObjectCategory::Books,
641            "Book Title".to_string(),
642            "Description".to_string(),
643            ObjectCondition::Good,
644            true,
645            None, // Free
646            None,
647            None,
648            None,
649            None,
650            None,
651        )
652        .unwrap();
653        assert!(object1.is_free());
654
655        // Free (0 credits)
656        let object2 = SharedObject::new(
657            owner_id,
658            building_id,
659            SharedObjectCategory::Books,
660            "Book Title".to_string(),
661            "Description".to_string(),
662            ObjectCondition::Good,
663            true,
664            Some(0), // Explicitly 0 credits
665            None,
666            None,
667            None,
668            None,
669            None,
670        )
671        .unwrap();
672        assert!(object2.is_free());
673
674        // Not free
675        let object3 = SharedObject::new(
676            owner_id,
677            building_id,
678            SharedObjectCategory::Electronics,
679            "Camera".to_string(),
680            "DSLR camera".to_string(),
681            ObjectCondition::Excellent,
682            true,
683            Some(10),
684            None,
685            None,
686            None,
687            None,
688            None,
689        )
690        .unwrap();
691        assert!(!object3.is_free());
692    }
693
694    #[test]
695    fn test_calculate_total_cost() {
696        let owner_id = Uuid::new_v4();
697        let building_id = Uuid::new_v4();
698        let borrower_id = Uuid::new_v4();
699
700        let mut object = SharedObject::new(
701            owner_id,
702            building_id,
703            SharedObjectCategory::Tools,
704            "Chainsaw".to_string(),
705            "Gas chainsaw".to_string(),
706            ObjectCondition::Good,
707            true,
708            Some(5),  // 5 credits/day
709            Some(30), // 30 credits deposit
710            Some(3),
711            None,
712            None,
713            None,
714        )
715        .unwrap();
716
717        object.borrow(borrower_id, Some(3)).unwrap();
718
719        let (rental_cost, deposit) = object.calculate_total_cost();
720        assert_eq!(deposit, 30);
721        assert!(rental_cost >= 5); // At least 1 day * 5 credits
722    }
723
724    #[test]
725    fn test_cannot_update_while_borrowed() {
726        let owner_id = Uuid::new_v4();
727        let building_id = Uuid::new_v4();
728        let borrower_id = Uuid::new_v4();
729
730        let mut object = SharedObject::new(
731            owner_id,
732            building_id,
733            SharedObjectCategory::Other,
734            "Item".to_string(),
735            "Description".to_string(),
736            ObjectCondition::Good,
737            true,
738            None,
739            None,
740            Some(7),
741            None,
742            None,
743            None,
744        )
745        .unwrap();
746
747        object.borrow(borrower_id, Some(7)).unwrap();
748
749        let result = object.update(
750            Some("New Name".to_string()),
751            None,
752            None,
753            None,
754            None,
755            None,
756            None,
757            None,
758            None,
759            None,
760        );
761
762        assert!(result.is_err());
763        assert_eq!(
764            result.unwrap_err(),
765            "Cannot update object while it is borrowed"
766        );
767    }
768
769    #[test]
770    fn test_mark_available_while_borrowed_fails() {
771        let owner_id = Uuid::new_v4();
772        let building_id = Uuid::new_v4();
773        let borrower_id = Uuid::new_v4();
774
775        let mut object = SharedObject::new(
776            owner_id,
777            building_id,
778            SharedObjectCategory::Sports,
779            "Tennis Racket".to_string(),
780            "Professional racket".to_string(),
781            ObjectCondition::Excellent,
782            true,
783            Some(2),
784            None,
785            Some(7),
786            None,
787            None,
788            None,
789        )
790        .unwrap();
791
792        object.borrow(borrower_id, Some(7)).unwrap();
793
794        let result = object.mark_available();
795        assert!(result.is_err());
796        assert_eq!(
797            result.unwrap_err(),
798            "Cannot mark as available while borrowed"
799        );
800    }
801}