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}