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    /// Optional tenant ID for multi-tenant isolation
34    ///
35    /// When set, this link belongs to a specific tenant.
36    /// When None, the link is treated as system-wide or single-tenant.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub tenant_id: Option<Uuid>,
39
40    /// The type of relationship (e.g., "owner", "driver", "worker")
41    pub link_type: String,
42
43    /// The ID of the source entity
44    pub source_id: Uuid,
45
46    /// The ID of the target entity
47    pub target_id: Uuid,
48
49    /// Optional metadata for the relationship
50    pub metadata: Option<serde_json::Value>,
51}
52
53impl LinkEntity {
54    /// Create a new link without tenant context
55    ///
56    /// For multi-tenant applications, use `new_with_tenant()` instead.
57    pub fn new(
58        link_type: impl Into<String>,
59        source_id: Uuid,
60        target_id: Uuid,
61        metadata: Option<serde_json::Value>,
62    ) -> Self {
63        let now = Utc::now();
64        Self {
65            id: Uuid::new_v4(),
66            entity_type: "link".to_string(),
67            created_at: now,
68            updated_at: now,
69            deleted_at: None,
70            status: "active".to_string(),
71            tenant_id: None,
72            link_type: link_type.into(),
73            source_id,
74            target_id,
75            metadata,
76        }
77    }
78
79    /// Create a new link with tenant context for multi-tenant applications
80    ///
81    /// # Example
82    ///
83    /// ```rust,ignore
84    /// use uuid::Uuid;
85    /// use this::core::link::LinkEntity;
86    ///
87    /// let tenant_id = Uuid::new_v4();
88    /// let link = LinkEntity::new_with_tenant(
89    ///     tenant_id,
90    ///     "has_invoice",
91    ///     order_id,
92    ///     invoice_id,
93    ///     None,
94    /// );
95    /// assert_eq!(link.tenant_id, Some(tenant_id));
96    /// ```
97    pub fn new_with_tenant(
98        tenant_id: Uuid,
99        link_type: impl Into<String>,
100        source_id: Uuid,
101        target_id: Uuid,
102        metadata: Option<serde_json::Value>,
103    ) -> Self {
104        let now = Utc::now();
105        Self {
106            id: Uuid::new_v4(),
107            entity_type: "link".to_string(),
108            created_at: now,
109            updated_at: now,
110            deleted_at: None,
111            status: "active".to_string(),
112            tenant_id: Some(tenant_id),
113            link_type: link_type.into(),
114            source_id,
115            target_id,
116            metadata,
117        }
118    }
119
120    /// Soft delete this link
121    pub fn soft_delete(&mut self) {
122        self.deleted_at = Some(Utc::now());
123        self.updated_at = Utc::now();
124    }
125
126    /// Restore a soft-deleted link
127    pub fn restore(&mut self) {
128        self.deleted_at = None;
129        self.updated_at = Utc::now();
130    }
131
132    /// Update the updated_at timestamp
133    pub fn touch(&mut self) {
134        self.updated_at = Utc::now();
135    }
136
137    /// Check if the link is deleted
138    pub fn is_deleted(&self) -> bool {
139        self.deleted_at.is_some()
140    }
141
142    /// Check if the link is active
143    pub fn is_active(&self) -> bool {
144        self.status == "active" && !self.is_deleted()
145    }
146}
147
148/// Authorization configuration for link operations
149///
150/// This allows fine-grained control over who can perform operations
151/// on specific link types, independent of entity-level permissions.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct LinkAuthConfig {
154    /// Policy for listing links (GET /{source}/{id}/{route_name})
155    #[serde(default = "default_link_auth_policy")]
156    pub list: String,
157
158    /// Policy for getting a specific link by ID
159    #[serde(default = "default_link_auth_policy")]
160    pub get: String,
161
162    /// Policy for creating a link
163    #[serde(default = "default_link_auth_policy")]
164    pub create: String,
165
166    /// Policy for updating a link
167    #[serde(default = "default_link_auth_policy")]
168    pub update: String,
169
170    /// Policy for deleting a link
171    #[serde(default = "default_link_auth_policy")]
172    pub delete: String,
173}
174
175fn default_link_auth_policy() -> String {
176    "authenticated".to_string()
177}
178
179impl Default for LinkAuthConfig {
180    fn default() -> Self {
181        Self {
182            list: default_link_auth_policy(),
183            get: default_link_auth_policy(),
184            create: default_link_auth_policy(),
185            update: default_link_auth_policy(),
186            delete: default_link_auth_policy(),
187        }
188    }
189}
190
191/// Configuration for a specific type of link between two entity types
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct LinkDefinition {
194    /// The type of link (e.g., "owner", "driver")
195    pub link_type: String,
196
197    /// The source entity type (e.g., "user")
198    pub source_type: String,
199
200    /// The target entity type (e.g., "car")
201    pub target_type: String,
202
203    /// Route name when navigating from source to target
204    pub forward_route_name: String,
205
206    /// Route name when navigating from target to source
207    pub reverse_route_name: String,
208
209    /// Optional description of this link type
210    pub description: Option<String>,
211
212    /// Optional list of required metadata fields
213    pub required_fields: Option<Vec<String>>,
214
215    /// Authorization configuration specific to this link type
216    #[serde(default)]
217    pub auth: Option<LinkAuthConfig>,
218}
219
220impl LinkDefinition {
221    /// Generate the default forward route name
222    pub fn default_forward_route_name(target_type: &str, link_type: &str) -> String {
223        format!(
224            "{}-{}",
225            Pluralizer::pluralize(target_type),
226            Pluralizer::pluralize(link_type)
227        )
228    }
229
230    /// Generate the default reverse route name
231    pub fn default_reverse_route_name(source_type: &str, link_type: &str) -> String {
232        format!(
233            "{}-{}",
234            Pluralizer::pluralize(source_type),
235            Pluralizer::pluralize(link_type)
236        )
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_link_creation() {
246        let user_id = Uuid::new_v4();
247        let car_id = Uuid::new_v4();
248
249        let link = LinkEntity::new("owner", user_id, car_id, None);
250
251        assert_eq!(link.link_type, "owner");
252        assert_eq!(link.source_id, user_id);
253        assert_eq!(link.target_id, car_id);
254        assert!(link.metadata.is_none());
255        assert!(link.tenant_id.is_none());
256        assert_eq!(link.status, "active");
257        assert!(!link.is_deleted());
258        assert!(link.is_active());
259    }
260
261    #[test]
262    fn test_link_creation_without_tenant() {
263        let user_id = Uuid::new_v4();
264        let car_id = Uuid::new_v4();
265
266        let link = LinkEntity::new("owner", user_id, car_id, None);
267
268        // Default behavior: no tenant
269        assert!(link.tenant_id.is_none());
270    }
271
272    #[test]
273    fn test_link_creation_with_tenant() {
274        let tenant_id = Uuid::new_v4();
275        let user_id = Uuid::new_v4();
276        let car_id = Uuid::new_v4();
277
278        let link = LinkEntity::new_with_tenant(tenant_id, "owner", user_id, car_id, None);
279
280        assert_eq!(link.link_type, "owner");
281        assert_eq!(link.source_id, user_id);
282        assert_eq!(link.target_id, car_id);
283        assert_eq!(link.tenant_id, Some(tenant_id));
284        assert_eq!(link.status, "active");
285    }
286
287    #[test]
288    fn test_link_with_tenant_and_metadata() {
289        let tenant_id = Uuid::new_v4();
290        let user_id = Uuid::new_v4();
291        let company_id = Uuid::new_v4();
292
293        let metadata = serde_json::json!({
294            "role": "Senior Developer",
295            "start_date": "2024-01-01"
296        });
297
298        let link = LinkEntity::new_with_tenant(
299            tenant_id,
300            "worker",
301            user_id,
302            company_id,
303            Some(metadata.clone()),
304        );
305
306        assert_eq!(link.tenant_id, Some(tenant_id));
307        assert_eq!(link.metadata, Some(metadata));
308    }
309
310    #[test]
311    fn test_link_serialization_without_tenant() {
312        let link = LinkEntity::new("owner", Uuid::new_v4(), Uuid::new_v4(), None);
313        let json = serde_json::to_value(&link).unwrap();
314
315        // tenant_id should not appear in JSON when None (skip_serializing_if)
316        assert!(json.get("tenant_id").is_none());
317    }
318
319    #[test]
320    fn test_link_serialization_with_tenant() {
321        let tenant_id = Uuid::new_v4();
322        let link =
323            LinkEntity::new_with_tenant(tenant_id, "owner", Uuid::new_v4(), Uuid::new_v4(), None);
324        let json = serde_json::to_value(&link).unwrap();
325
326        // tenant_id should appear in JSON when Some
327        assert_eq!(
328            json.get("tenant_id").and_then(|v| v.as_str()),
329            Some(tenant_id.to_string().as_str())
330        );
331    }
332
333    #[test]
334    fn test_link_with_metadata() {
335        let user_id = Uuid::new_v4();
336        let company_id = Uuid::new_v4();
337
338        let metadata = serde_json::json!({
339            "role": "Senior Developer",
340            "start_date": "2024-01-01"
341        });
342
343        let link = LinkEntity::new("worker", user_id, company_id, Some(metadata.clone()));
344
345        assert_eq!(link.metadata, Some(metadata));
346    }
347
348    #[test]
349    fn test_link_soft_delete() {
350        let mut link = LinkEntity::new("owner", Uuid::new_v4(), Uuid::new_v4(), None);
351
352        assert!(!link.is_deleted());
353        assert!(link.is_active());
354
355        link.soft_delete();
356        assert!(link.is_deleted());
357        assert!(!link.is_active());
358    }
359
360    #[test]
361    fn test_link_restore() {
362        let mut link = LinkEntity::new("owner", Uuid::new_v4(), Uuid::new_v4(), None);
363
364        link.soft_delete();
365        assert!(link.is_deleted());
366
367        link.restore();
368        assert!(!link.is_deleted());
369        assert!(link.is_active());
370    }
371
372    #[test]
373    fn test_default_route_names() {
374        let forward = LinkDefinition::default_forward_route_name("car", "owner");
375        assert_eq!(forward, "cars-owners");
376
377        let reverse = LinkDefinition::default_reverse_route_name("user", "owner");
378        assert_eq!(reverse, "users-owners");
379    }
380
381    #[test]
382    fn test_route_names_with_irregular_plurals() {
383        let forward = LinkDefinition::default_forward_route_name("company", "owner");
384        assert_eq!(forward, "companies-owners");
385
386        let reverse = LinkDefinition::default_reverse_route_name("company", "worker");
387        assert_eq!(reverse, "companies-workers");
388    }
389
390    #[test]
391    fn test_link_auth_config_default() {
392        let auth = LinkAuthConfig::default();
393        assert_eq!(auth.list, "authenticated");
394        assert_eq!(auth.get, "authenticated");
395        assert_eq!(auth.create, "authenticated");
396        assert_eq!(auth.update, "authenticated");
397        assert_eq!(auth.delete, "authenticated");
398    }
399
400    #[test]
401    fn test_link_definition_with_auth() {
402        let yaml = r#"
403            link_type: has_invoice
404            source_type: order
405            target_type: invoice
406            forward_route_name: invoices
407            reverse_route_name: order
408            auth:
409                list: authenticated
410                get: owner
411                create: service_only
412                update: owner
413                delete: admin_only
414        "#;
415
416        let def: LinkDefinition = serde_yaml::from_str(yaml).unwrap();
417        assert_eq!(def.link_type, "has_invoice");
418        assert_eq!(def.source_type, "order");
419        assert_eq!(def.target_type, "invoice");
420
421        let auth = def.auth.unwrap();
422        assert_eq!(auth.list, "authenticated");
423        assert_eq!(auth.get, "owner");
424        assert_eq!(auth.create, "service_only");
425        assert_eq!(auth.update, "owner");
426        assert_eq!(auth.delete, "admin_only");
427    }
428}