Skip to main content

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