/home/runner/work/tenet/tenet/tenet-aws/src/models/member.rs
Line | Count | Source |
1 | | use std::collections::HashMap; |
2 | | |
3 | | use aws_sdk_dynamodb::types::AttributeValue; |
4 | | use chrono::{DateTime, Utc}; |
5 | | use serde::{Deserialize, Serialize}; |
6 | | use serde_dynamo::to_item; |
7 | | |
8 | | use crate::{ |
9 | | ApiError, |
10 | | clock::{Clock, SystemClock}, |
11 | | db::{get_string, insert_entity_type, string_or_error, time_or_error}, |
12 | | models::{organisation::OrganisationId, profile::UserId, role::Role}, |
13 | | }; |
14 | | |
15 | | /// Represents a member of an organisation |
16 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
17 | | pub struct Member { |
18 | | /// The user's Cognito sub (unique identifier) |
19 | | pub user_id: UserId, |
20 | | /// The organisation this membership belongs to |
21 | | pub organisation_id: OrganisationId, |
22 | | /// The member's email address |
23 | | pub email: String, |
24 | | /// The member's display name (optional) |
25 | | #[serde(skip_serializing_if = "Option::is_none")] |
26 | | pub name: Option<String>, |
27 | | /// The member's role in this organisation |
28 | | pub role: Role, |
29 | | /// Who invited this member (null if they are the org creator) |
30 | | #[serde(skip_serializing_if = "Option::is_none")] |
31 | | pub invited_by: Option<UserId>, |
32 | | /// When the member joined the organisation |
33 | | pub joined_at: DateTime<Utc>, |
34 | | } |
35 | | |
36 | | impl Member { |
37 | | /// Create a new member (e.g., when accepting an invitation) |
38 | 110 | pub fn new( |
39 | 110 | user_id: UserId, |
40 | 110 | organisation_id: OrganisationId, |
41 | 110 | email: String, |
42 | 110 | name: Option<String>, |
43 | 110 | role: Role, |
44 | 110 | invited_by: Option<UserId>, |
45 | 110 | ) -> Self { |
46 | 110 | Self::new_with_clock( |
47 | 110 | &SystemClock, |
48 | 110 | user_id, |
49 | 110 | organisation_id, |
50 | 110 | email, |
51 | 110 | name, |
52 | 110 | role, |
53 | 110 | invited_by, |
54 | | ) |
55 | 110 | } |
56 | | |
57 | 110 | pub fn new_with_clock<C: Clock>( |
58 | 110 | clock: &C, |
59 | 110 | user_id: UserId, |
60 | 110 | organisation_id: OrganisationId, |
61 | 110 | email: String, |
62 | 110 | name: Option<String>, |
63 | 110 | role: Role, |
64 | 110 | invited_by: Option<UserId>, |
65 | 110 | ) -> Self { |
66 | 110 | Self { |
67 | 110 | user_id, |
68 | 110 | organisation_id, |
69 | 110 | email, |
70 | 110 | name, |
71 | 110 | role, |
72 | 110 | invited_by, |
73 | 110 | joined_at: clock.now(), |
74 | 110 | } |
75 | 110 | } |
76 | | |
77 | | /// Create the owner member when creating a new organisation |
78 | 30 | pub fn new_owner( |
79 | 30 | user_id: UserId, |
80 | 30 | organisation_id: OrganisationId, |
81 | 30 | email: String, |
82 | 30 | name: Option<String>, |
83 | 30 | ) -> Self { |
84 | 30 | Self::new_owner_with_clock(&SystemClock, user_id, organisation_id, email, name) |
85 | 30 | } |
86 | | |
87 | 30 | pub fn new_owner_with_clock<C: Clock>( |
88 | 30 | clock: &C, |
89 | 30 | user_id: UserId, |
90 | 30 | organisation_id: OrganisationId, |
91 | 30 | email: String, |
92 | 30 | name: Option<String>, |
93 | 30 | ) -> Self { |
94 | 30 | Self { |
95 | 30 | user_id, |
96 | 30 | organisation_id, |
97 | 30 | email, |
98 | 30 | name, |
99 | 30 | role: Role::Admin, |
100 | 30 | invited_by: None, |
101 | 30 | joined_at: clock.now(), |
102 | 30 | } |
103 | 30 | } |
104 | | } |
105 | | |
106 | | /// Simplified member view for API responses |
107 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
108 | | pub struct MemberView { |
109 | | pub user_id: UserId, |
110 | | pub email: String, |
111 | | #[serde(skip_serializing_if = "Option::is_none")] |
112 | | pub name: Option<String>, |
113 | | pub role: Role, |
114 | | pub joined_at: DateTime<Utc>, |
115 | | } |
116 | | |
117 | | impl From<&Member> for MemberView { |
118 | 2 | fn from(member: &Member) -> Self { |
119 | 2 | Self { |
120 | 2 | user_id: member.user_id.clone(), |
121 | 2 | email: member.email.clone(), |
122 | 2 | name: member.name.clone(), |
123 | 2 | role: member.role, |
124 | 2 | joined_at: member.joined_at, |
125 | 2 | } |
126 | 2 | } |
127 | | } |
128 | | |
129 | | impl TryFrom<&Member> for HashMap<String, AttributeValue> { |
130 | | type Error = ApiError; |
131 | | |
132 | 1 | fn try_from(member: &Member) -> Result<Self, Self::Error> { |
133 | 1 | let item: HashMap<String, AttributeValue> = |
134 | 1 | to_item(member).map_err(|e| ApiError::InvalidRequest(e0 .to_string0 ()))?0 ; |
135 | 1 | Ok(insert_entity_type(item, "Member")) |
136 | 1 | } |
137 | | } |
138 | | |
139 | | impl TryFrom<&HashMap<String, AttributeValue>> for Member { |
140 | | type Error = ApiError; |
141 | | |
142 | 2 | fn try_from(value: &HashMap<String, AttributeValue>) -> Result<Self, Self::Error> { |
143 | | Ok(Member { |
144 | 2 | user_id: string_or_error(value, "user_id")?0 .try_into()?0 , |
145 | 2 | organisation_id: string_or_error(value, "organisation_id")?0 .try_into()?0 , |
146 | 2 | email: string_or_error(value, "email")?0 , |
147 | 2 | name: get_string(value, "name"), |
148 | 2 | role: string_or_error(value, "role")?0 |
149 | 2 | .parse() |
150 | 2 | .map_err(|e: String| ApiError::InvalidRequest(e1 ))?1 , |
151 | 1 | invited_by: get_string(value, "invited_by") |
152 | 1 | .map(|id| id.try_into()) |
153 | 1 | .transpose()?0 , |
154 | 1 | joined_at: time_or_error(value, "joined_at")?0 , |
155 | | }) |
156 | 2 | } |
157 | | } |
158 | | |
159 | | impl TryFrom<HashMap<String, AttributeValue>> for Member { |
160 | | type Error = ApiError; |
161 | | |
162 | 0 | fn try_from(value: HashMap<String, AttributeValue>) -> Result<Self, Self::Error> { |
163 | 0 | Member::try_from(&value) |
164 | 0 | } |
165 | | } |
166 | | |
167 | | /// Request to add an existing user as a member |
168 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
169 | | pub struct AddMemberRequest { |
170 | | /// The user ID (Cognito sub) to add |
171 | | pub user_id: String, |
172 | | /// The email of the user |
173 | | pub email: String, |
174 | | /// Optional display name |
175 | | #[serde(skip_serializing_if = "Option::is_none")] |
176 | | pub name: Option<String>, |
177 | | /// The role to assign |
178 | | pub role: Role, |
179 | | } |
180 | | |
181 | | /// Request to update a member's role |
182 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
183 | | pub struct UpdateMemberRoleRequest { |
184 | | pub role: Role, |
185 | | } |
186 | | |
187 | | #[cfg(test)] |
188 | | mod tests { |
189 | | use super::*; |
190 | | use crate::test_support::must_ok; |
191 | | |
192 | 3 | fn user_id(seed: &str) -> UserId { |
193 | 3 | must_ok(seed.to_string().try_into()) |
194 | 3 | } |
195 | | |
196 | 2 | fn org_id(seed: &str) -> OrganisationId { |
197 | 2 | must_ok(seed.to_string().try_into()) |
198 | 2 | } |
199 | | |
200 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
201 | | #[allure_rs::allure_test] |
202 | | #[test] |
203 | | fn test_member_new_owner_sets_admin_defaults() { |
204 | | let owner = Member::new_owner( |
205 | | user_id("550e8400-e29b-41d4-a716-446655440000"), |
206 | | org_id("550e8400-e29b-41d4-a716-446655440001"), |
207 | | "owner@example.com".to_string(), |
208 | | Some("Owner".to_string()), |
209 | | ); |
210 | | |
211 | | assert_eq!(owner.role, Role::Admin); |
212 | | assert_eq!(owner.invited_by, None); |
213 | | assert_eq!(owner.email, "owner@example.com"); |
214 | | } |
215 | | |
216 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
217 | | #[allure_rs::allure_test] |
218 | | #[test] |
219 | | fn test_member_roundtrip_item_and_view() { |
220 | | let member = Member::new( |
221 | | user_id("550e8400-e29b-41d4-a716-446655440000"), |
222 | | org_id("550e8400-e29b-41d4-a716-446655440001"), |
223 | | "member@example.com".to_string(), |
224 | | Some("Member".to_string()), |
225 | | Role::User, |
226 | | Some(user_id("550e8400-e29b-41d4-a716-446655440002")), |
227 | | ); |
228 | | |
229 | | let item: HashMap<String, AttributeValue> = must_ok((&member).try_into()); |
230 | | let decoded = must_ok(Member::try_from(&item)); |
231 | | let view = MemberView::from(&decoded); |
232 | | |
233 | | assert_eq!(decoded.user_id, member.user_id); |
234 | | assert_eq!(decoded.organisation_id, member.organisation_id); |
235 | | assert_eq!(decoded.role, Role::User); |
236 | | assert_eq!(view.email, "member@example.com"); |
237 | | assert_eq!(view.name.as_deref(), Some("Member")); |
238 | | } |
239 | | |
240 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
241 | | #[allure_rs::allure_test] |
242 | | #[test] |
243 | | fn test_member_try_from_rejects_invalid_role() { |
244 | | let mut item: HashMap<String, AttributeValue> = HashMap::new(); |
245 | | item.insert( |
246 | | "user_id".to_string(), |
247 | | AttributeValue::S("550e8400-e29b-41d4-a716-446655440000".to_string()), |
248 | | ); |
249 | | item.insert( |
250 | | "organisation_id".to_string(), |
251 | | AttributeValue::S("550e8400-e29b-41d4-a716-446655440001".to_string()), |
252 | | ); |
253 | | item.insert( |
254 | | "email".to_string(), |
255 | | AttributeValue::S("member@example.com".to_string()), |
256 | | ); |
257 | | item.insert( |
258 | | "role".to_string(), |
259 | | AttributeValue::S("super-admin".to_string()), |
260 | | ); |
261 | | item.insert( |
262 | | "joined_at".to_string(), |
263 | | AttributeValue::S(chrono::Utc::now().to_rfc3339()), |
264 | | ); |
265 | | |
266 | | let err = Member::try_from(&item).expect_err("invalid role should fail decoding"); |
267 | | assert!( |
268 | | err.to_string().contains("Invalid role"), |
269 | | "unexpected error: {err}" |
270 | | ); |
271 | | } |
272 | | } |