revelation_user/projections/
public.rs

1// SPDX-FileCopyrightText: 2025 Revelation Team
2// SPDX-License-Identifier: MIT
3
4//! Public user projection for API responses.
5//!
6//! This module provides [`RUserPublic`], a read-only view of user data
7//! that is safe to expose in API responses. Sensitive fields like
8//! email, phone, and telegram_id are excluded.
9//!
10//! # Security
11//!
12//! Using projections instead of the full [`RUser`] entity ensures
13//! that sensitive data cannot be accidentally serialized and sent
14//! to clients:
15//!
16//! ```rust
17//! use revelation_user::{RUser, RUserPublic};
18//!
19//! // User with sensitive data
20//! let user = RUser::from_email("secret@example.com");
21//!
22//! let public: RUserPublic = user.into();
23//!
24//! // JSON output: {"id":"...","name":null,"gender":null}
25//! // Note: email and telegram_id are NOT included
26//! ```
27//!
28//! # Examples
29//!
30//! ## Basic Usage
31//!
32//! ```rust
33//! use revelation_user::{Gender, RUser, RUserPublic};
34//!
35//! let mut user = RUser::empty();
36//! user.name = Some("Alice".into());
37//! user.gender = Some(Gender::Female);
38//!
39//! let public: RUserPublic = user.into();
40//! assert_eq!(public.name.as_deref(), Some("Alice"));
41//! assert_eq!(public.gender, Some(Gender::Female));
42//! ```
43//!
44//! ## Reference Conversion
45//!
46//! ```rust
47//! use revelation_user::{RUser, RUserPublic};
48//!
49//! let user = RUser::from_telegram(123456);
50//!
51//! // Convert without consuming the user
52//! let public: RUserPublic = (&user).into();
53//!
54//! // user is still available
55//! assert!(user.telegram_id.is_some());
56//! ```
57//!
58//! [`RUser`]: crate::RUser
59
60use serde::{Deserialize, Serialize};
61use uuid::Uuid;
62
63use crate::{Gender, RUser};
64
65/// Public user data safe for API responses.
66///
67/// This projection contains only non-sensitive user information
68/// that can be safely exposed to clients.
69///
70/// # Fields
71///
72/// | Field | Type | Description |
73/// |-------|------|-------------|
74/// | `id` | `Uuid` | Unique user identifier |
75/// | `name` | `Option<String>` | Display name |
76/// | `gender` | `Option<Gender>` | User's gender |
77///
78/// # Excluded Fields
79///
80/// The following [`RUser`] fields are intentionally excluded:
81/// - `telegram_id` - Authentication identifier
82/// - `email` - Personal contact information
83/// - `phone` - Personal contact information
84/// - `birth_date` - Sensitive personal data
85/// - `confession_id` - Religious information
86/// - `created_at` - Internal metadata
87///
88/// # Examples
89///
90/// ## From Owned [`RUser`]
91///
92/// ```rust
93/// use revelation_user::{RUser, RUserPublic};
94///
95/// let user = RUser::from_telegram(123456789);
96/// let public: RUserPublic = user.into();
97/// ```
98///
99/// ## From Reference
100///
101/// ```rust
102/// use revelation_user::{RUser, RUserPublic};
103///
104/// let user = RUser::from_email("user@example.com");
105/// let public: RUserPublic = (&user).into();
106///
107/// // Original user still available
108/// assert!(user.email.is_some());
109/// ```
110///
111/// ## JSON Serialization
112///
113/// ```rust
114/// use revelation_user::{Gender, RUser, RUserPublic};
115/// use uuid::Uuid;
116///
117/// let mut user = RUser::with_id(Uuid::nil());
118/// user.name = Some("Test User".into());
119/// user.gender = Some(Gender::Male);
120/// user.email = Some("secret@test.com".into());
121///
122/// let public: RUserPublic = user.into();
123/// let json = serde_json::to_string(&public).unwrap();
124///
125/// assert!(json.contains("Test User"));
126/// assert!(!json.contains("secret@test.com"));
127/// ```
128///
129/// [`RUser`]: crate::RUser
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
132pub struct RUserPublic {
133    /// Unique user identifier.
134    ///
135    /// This is the same UUID from the source [`RUser`].
136    ///
137    /// [`RUser`]: crate::RUser
138    pub id: Uuid,
139
140    /// Display name.
141    ///
142    /// User's chosen display name, if set. May be `None` for
143    /// users who haven't completed their profile.
144    pub name: Option<String>,
145
146    /// User's gender.
147    ///
148    /// Optional gender information, if provided by the user.
149    pub gender: Option<Gender>
150}
151
152impl From<RUser> for RUserPublic {
153    /// Converts an owned [`RUser`] into [`RUserPublic`].
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use revelation_user::{RUser, RUserPublic};
159    ///
160    /// let user = RUser::from_telegram(123456);
161    /// let public: RUserPublic = user.into();
162    /// ```
163    ///
164    /// [`RUser`]: crate::RUser
165    fn from(user: RUser) -> Self {
166        Self {
167            id:     user.id,
168            name:   user.name,
169            gender: user.gender
170        }
171    }
172}
173
174impl From<&RUser> for RUserPublic {
175    /// Converts a reference to [`RUser`] into [`RUserPublic`].
176    ///
177    /// This allows creating a public projection without
178    /// consuming the original user.
179    ///
180    /// # Examples
181    ///
182    /// ```rust
183    /// use revelation_user::{RUser, RUserPublic};
184    ///
185    /// let user = RUser::from_email("user@example.com");
186    /// let public: RUserPublic = (&user).into();
187    ///
188    /// // user is still available
189    /// assert!(user.email.is_some());
190    /// ```
191    ///
192    /// [`RUser`]: crate::RUser
193    fn from(user: &RUser) -> Self {
194        Self {
195            id:     user.id,
196            name:   user.name.clone(),
197            gender: user.gender
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn from_user_copies_public_fields() {
208        let mut user = RUser::with_id(Uuid::nil());
209        user.name = Some("Test".into());
210        user.gender = Some(Gender::Male);
211        user.email = Some("secret@test.com".into());
212        user.telegram_id = Some(123);
213
214        let public: RUserPublic = user.into();
215
216        assert_eq!(public.id, Uuid::nil());
217        assert_eq!(public.name.as_deref(), Some("Test"));
218        assert_eq!(public.gender, Some(Gender::Male));
219    }
220
221    #[test]
222    fn from_ref_preserves_original() {
223        let user = RUser::from_telegram(123456);
224        let _public: RUserPublic = (&user).into();
225
226        // User still accessible
227        assert_eq!(user.telegram_id, Some(123456));
228    }
229
230    #[test]
231    fn serialization_excludes_sensitive_fields() {
232        let mut user = RUser::with_id(Uuid::nil());
233        user.email = Some("secret@test.com".into());
234
235        let public: RUserPublic = user.into();
236        let json = serde_json::to_string(&public).unwrap();
237
238        assert!(!json.contains("secret@test.com"));
239        assert!(!json.contains("telegram_id"));
240    }
241}