this/core/
link.rs

1//! Link system for managing relationships between entities
2
3use crate::core::pluralize::Pluralizer;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// A polymorphic link between two entities
9///
10/// Links follow the Entity model with base fields (id, type, timestamps, status)
11/// plus relationship-specific fields (source_id, target_id, link_type).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LinkEntity {
14    /// Unique identifier for this link
15    pub id: Uuid,
16
17    /// Entity type (always "link" for base links)
18    #[serde(rename = "type")]
19    pub entity_type: String,
20
21    /// When this link was created
22    pub created_at: DateTime<Utc>,
23
24    /// When this link was last updated
25    pub updated_at: DateTime<Utc>,
26
27    /// When this link was soft-deleted (if applicable)
28    pub deleted_at: Option<DateTime<Utc>>,
29
30    /// Status of the link
31    pub status: String,
32
33    /// The type of relationship (e.g., "owner", "driver", "worker")
34    pub link_type: String,
35
36    /// The ID of the source entity
37    pub source_id: Uuid,
38
39    /// The ID of the target entity
40    pub target_id: Uuid,
41
42    /// Optional metadata for the relationship
43    pub metadata: Option<serde_json::Value>,
44}
45
46impl LinkEntity {
47    /// Create a new link
48    pub fn new(
49        link_type: impl Into<String>,
50        source_id: Uuid,
51        target_id: Uuid,
52        metadata: Option<serde_json::Value>,
53    ) -> Self {
54        let now = Utc::now();
55        Self {
56            id: Uuid::new_v4(),
57            entity_type: "link".to_string(),
58            created_at: now,
59            updated_at: now,
60            deleted_at: None,
61            status: "active".to_string(),
62            link_type: link_type.into(),
63            source_id,
64            target_id,
65            metadata,
66        }
67    }
68
69    /// Soft delete this link
70    pub fn soft_delete(&mut self) {
71        self.deleted_at = Some(Utc::now());
72        self.updated_at = Utc::now();
73    }
74
75    /// Restore a soft-deleted link
76    pub fn restore(&mut self) {
77        self.deleted_at = None;
78        self.updated_at = Utc::now();
79    }
80
81    /// Update the updated_at timestamp
82    pub fn touch(&mut self) {
83        self.updated_at = Utc::now();
84    }
85
86    /// Check if the link is deleted
87    pub fn is_deleted(&self) -> bool {
88        self.deleted_at.is_some()
89    }
90
91    /// Check if the link is active
92    pub fn is_active(&self) -> bool {
93        self.status == "active" && !self.is_deleted()
94    }
95}
96
97/// Authorization configuration for link operations
98///
99/// This allows fine-grained control over who can perform operations
100/// on specific link types, independent of entity-level permissions.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct LinkAuthConfig {
103    /// Policy for listing links (GET /{source}/{id}/{route_name})
104    #[serde(default = "default_link_auth_policy")]
105    pub list: String,
106
107    /// Policy for getting a specific link by ID
108    #[serde(default = "default_link_auth_policy")]
109    pub get: String,
110
111    /// Policy for creating a link
112    #[serde(default = "default_link_auth_policy")]
113    pub create: String,
114
115    /// Policy for updating a link
116    #[serde(default = "default_link_auth_policy")]
117    pub update: String,
118
119    /// Policy for deleting a link
120    #[serde(default = "default_link_auth_policy")]
121    pub delete: String,
122}
123
124fn default_link_auth_policy() -> String {
125    "authenticated".to_string()
126}
127
128impl Default for LinkAuthConfig {
129    fn default() -> Self {
130        Self {
131            list: default_link_auth_policy(),
132            get: default_link_auth_policy(),
133            create: default_link_auth_policy(),
134            update: default_link_auth_policy(),
135            delete: default_link_auth_policy(),
136        }
137    }
138}
139
140/// Configuration for a specific type of link between two entity types
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct LinkDefinition {
143    /// The type of link (e.g., "owner", "driver")
144    pub link_type: String,
145
146    /// The source entity type (e.g., "user")
147    pub source_type: String,
148
149    /// The target entity type (e.g., "car")
150    pub target_type: String,
151
152    /// Route name when navigating from source to target
153    pub forward_route_name: String,
154
155    /// Route name when navigating from target to source
156    pub reverse_route_name: String,
157
158    /// Optional description of this link type
159    pub description: Option<String>,
160
161    /// Optional list of required metadata fields
162    pub required_fields: Option<Vec<String>>,
163
164    /// Authorization configuration specific to this link type
165    #[serde(default)]
166    pub auth: Option<LinkAuthConfig>,
167}
168
169impl LinkDefinition {
170    /// Generate the default forward route name
171    pub fn default_forward_route_name(target_type: &str, link_type: &str) -> String {
172        format!(
173            "{}-{}",
174            Pluralizer::pluralize(target_type),
175            Pluralizer::pluralize(link_type)
176        )
177    }
178
179    /// Generate the default reverse route name
180    pub fn default_reverse_route_name(source_type: &str, link_type: &str) -> String {
181        format!(
182            "{}-{}",
183            Pluralizer::pluralize(source_type),
184            Pluralizer::pluralize(link_type)
185        )
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_link_creation() {
195        let user_id = Uuid::new_v4();
196        let car_id = Uuid::new_v4();
197
198        let link = LinkEntity::new("owner", user_id, car_id, None);
199
200        assert_eq!(link.link_type, "owner");
201        assert_eq!(link.source_id, user_id);
202        assert_eq!(link.target_id, car_id);
203        assert!(link.metadata.is_none());
204        assert_eq!(link.status, "active");
205        assert!(!link.is_deleted());
206        assert!(link.is_active());
207    }
208
209    #[test]
210    fn test_link_with_metadata() {
211        let user_id = Uuid::new_v4();
212        let company_id = Uuid::new_v4();
213
214        let metadata = serde_json::json!({
215            "role": "Senior Developer",
216            "start_date": "2024-01-01"
217        });
218
219        let link = LinkEntity::new("worker", user_id, company_id, Some(metadata.clone()));
220
221        assert_eq!(link.metadata, Some(metadata));
222    }
223
224    #[test]
225    fn test_link_soft_delete() {
226        let mut link = LinkEntity::new("owner", Uuid::new_v4(), Uuid::new_v4(), None);
227
228        assert!(!link.is_deleted());
229        assert!(link.is_active());
230
231        link.soft_delete();
232        assert!(link.is_deleted());
233        assert!(!link.is_active());
234    }
235
236    #[test]
237    fn test_link_restore() {
238        let mut link = LinkEntity::new("owner", Uuid::new_v4(), Uuid::new_v4(), None);
239
240        link.soft_delete();
241        assert!(link.is_deleted());
242
243        link.restore();
244        assert!(!link.is_deleted());
245        assert!(link.is_active());
246    }
247
248    #[test]
249    fn test_default_route_names() {
250        let forward = LinkDefinition::default_forward_route_name("car", "owner");
251        assert_eq!(forward, "cars-owners");
252
253        let reverse = LinkDefinition::default_reverse_route_name("user", "owner");
254        assert_eq!(reverse, "users-owners");
255    }
256
257    #[test]
258    fn test_route_names_with_irregular_plurals() {
259        let forward = LinkDefinition::default_forward_route_name("company", "owner");
260        assert_eq!(forward, "companies-owners");
261
262        let reverse = LinkDefinition::default_reverse_route_name("company", "worker");
263        assert_eq!(reverse, "companies-workers");
264    }
265
266    #[test]
267    fn test_link_auth_config_default() {
268        let auth = LinkAuthConfig::default();
269        assert_eq!(auth.list, "authenticated");
270        assert_eq!(auth.get, "authenticated");
271        assert_eq!(auth.create, "authenticated");
272        assert_eq!(auth.update, "authenticated");
273        assert_eq!(auth.delete, "authenticated");
274    }
275
276    #[test]
277    fn test_link_definition_with_auth() {
278        let yaml = r#"
279            link_type: has_invoice
280            source_type: order
281            target_type: invoice
282            forward_route_name: invoices
283            reverse_route_name: order
284            auth:
285                list: authenticated
286                get: owner
287                create: service_only
288                update: owner
289                delete: admin_only
290        "#;
291
292        let def: LinkDefinition = serde_yaml::from_str(yaml).unwrap();
293        assert_eq!(def.link_type, "has_invoice");
294        assert_eq!(def.source_type, "order");
295        assert_eq!(def.target_type, "invoice");
296
297        let auth = def.auth.unwrap();
298        assert_eq!(auth.list, "authenticated");
299        assert_eq!(auth.get, "owner");
300        assert_eq!(auth.create, "service_only");
301        assert_eq!(auth.update, "owner");
302        assert_eq!(auth.delete, "admin_only");
303    }
304}