Skip to content
Clone

feat(user): add me_spaces profile endpoint exposing caller's space memberships

zhenyi committed
parent 540fa21 commit 0285e7e Browse files

9 files changed

+204 −8

File tree 9
Modified lib/export/space/spaces.rs View lib/export/space/spaces.rs
@@ -56,7 +56,7 @@
5656 }
5757}
5858
59#[derive(Debug, Clone, Serialize, ToSchema)]
59#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
6060pub struct SpaceView {
6161 pub id: Uuid,
6262 pub name: String,
Modified lib/user/features/profile/impls.rs View lib/user/features/profile/impls.rs
@@ -3,7 +3,10 @@
33use crate::error::UserResult;
44use crate::features::UserService;
55
6use super::types::{MemeView, ProfileView, UpdateProfileCmd, UpsertMemeCmd, UserMe, UserPublic};
6use super::types::{
7 MemeView, ProfileView, UpdateProfileCmd, UpsertMemeCmd, UserMe, UserPublic,
8 UserSpacePermissionView,
9};
710use super::usecase;
811
912impl UserService {
@@ -39,4 +42,11 @@
3942 pub async fn delete_meme(&self, caller: Uuid, meme_id: Uuid) -> UserResult<()> {
4043 usecase::delete_meme(self, caller, meme_id).await
4144 }
45
46 pub async fn get_my_spaces(
47 &self,
48 uid: Uuid,
49 ) -> UserResult<Vec<UserSpacePermissionView>> {
50 usecase::fetch_my_spaces(self, uid).await
51 }
4252}
Modified lib/user/features/profile/types.rs View lib/user/features/profile/types.rs
@@ -6,6 +6,7 @@
66use uuid::Uuid;
77
88use config::user::ProfileVisibility;
9use export::space::spaces::SpaceView;
910
1011fn fmt_ts(ts: OffsetDateTime) -> String {
1112 ts.format(&Rfc3339).unwrap_or_default()
@@ -42,8 +43,17 @@
4243 pub language: Option<String>,
4344 pub timezone: Option<String>,
4445 pub created_at: String,
46 #[serde(default)]
47 pub common_spaces: Vec<SpaceView>,
4548}
4649
50#[derive(Debug, Clone, Serialize, ToSchema)]
51pub struct UserSpacePermissionView {
52 #[serde(flatten)]
53 pub space: SpaceView,
54 pub permissions: Vec<String>,
55}
56
4757impl From<user_entity::Model> for UserPublic {
4858 fn from(m: user_entity::Model) -> Self {
4959 Self {
@@ -58,6 +68,7 @@
5868 language: m.language,
5969 timezone: m.timezone,
6070 created_at: fmt_ts(m.created_at),
71 common_spaces: Vec::new(),
6172 }
6273 }
6374}
Modified lib/user/features/profile/usecase.rs View lib/user/features/profile/usecase.rs
@@ -1,7 +1,15 @@
1use std::collections::HashSet;
2
13use db::conn::sea_orm::{
2 ActiveModelTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
4 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set, TransactionTrait,
36};
7use db::space::space_member_roles as member_role_entity;
8use db::space::space_members as member_entity;
9use db::space::space_role_permissions as role_perm_entity;
10use db::space::spaces as space_entity;
411use db::user::users as user_entity;
12use export::space::spaces::SpaceView;
513use time::OffsetDateTime;
614use uuid::Uuid;
715
@@ -11,7 +19,7 @@
1119
1220use super::types::{
1321 MemeView, ProfileView, UpdateProfileCmd, UpdateProfileFields, UpsertMemeCmd, UserMe,
14 UserPublic, visibility_to_entity,
22 UserPublic, UserSpacePermissionView, visibility_to_entity,
1523};
1624
1725const MAX_DISPLAY_NAME: usize = 64;
@@ -53,13 +61,46 @@
5361 if !can_view(&model, viewer) {
5462 return Err(UserError::Forbidden(format!("user {uid} is private")));
5563 }
56 Ok(UserPublic::from(model))
64 let mut public = UserPublic::from(model);
65 public.common_spaces = fetch_common_spaces(&db, uid, viewer).await?;
66 Ok(public)
5767 }
5868 .await;
5969 metrics::record_outcome(metrics::FEATURE_PROFILE_PUBLIC_VIEW, &result);
6070 result
6171}
6272
73pub async fn fetch_my_spaces(
74 svc: &UserService,
75 uid: Uuid,
76) -> UserResult<Vec<UserSpacePermissionView>> {
77 let _timer = metrics::Timer::start(metrics::FEATURE_PROFILE_ME_SPACES);
78 let result: UserResult<Vec<UserSpacePermissionView>> = async {
79 let db = svc.db.reader();
80 let ids = active_space_ids(&db, uid).await?;
81 if ids.is_empty() {
82 return Ok(Vec::new());
83 }
84 let models = space_entity::Entity::find()
85 .filter(space_entity::Column::Id.is_in(ids))
86 .order_by_asc(space_entity::Column::LowerName)
87 .all(&db)
88 .await?;
89 let mut views = Vec::with_capacity(models.len());
90 for model in models {
91 let permissions = effective_permissions(&db, &model, uid).await?;
92 views.push(UserSpacePermissionView {
93 space: SpaceView::from(model),
94 permissions,
95 });
96 }
97 Ok(views)
98 }
99 .await;
100 metrics::record_outcome(metrics::FEATURE_PROFILE_ME_SPACES, &result);
101 result
102}
103
63104pub async fn update_profile(
64105 svc: &UserService,
65106 caller: Uuid,
@@ -256,6 +297,110 @@
256297 result
257298}
258299
300const PERM_VIEW_SPACE: &str = "view_space";
301const PERM_READ: &str = "read";
302const PERM_MESSAGE: &str = "message";
303const PERM_MANAGE_MEMBERS: &str = "manage_members";
304const PERM_MANAGE_ROLES: &str = "manage_roles";
305const PERM_MANAGE_INVITES: &str = "manage_invites";
306const PERM_MANAGE_BANS: &str = "manage_bans";
307const PERM_MANAGE_CHANNELS: &str = "manage_channels";
308const PERM_MANAGE_OVERWRITES: &str = "manage_overwrites";
309
310const OWNER_PERMISSIONS: [&str; 9] = [
311 PERM_VIEW_SPACE,
312 PERM_READ,
313 PERM_MESSAGE,
314 PERM_MANAGE_MEMBERS,
315 PERM_MANAGE_ROLES,
316 PERM_MANAGE_INVITES,
317 PERM_MANAGE_BANS,
318 PERM_MANAGE_CHANNELS,
319 PERM_MANAGE_OVERWRITES,
320];
321
322async fn active_space_ids<C>(db: &C, user_id: Uuid) -> UserResult<HashSet<Uuid>>
323where
324 C: ConnectionTrait,
325{
326 let members = member_entity::Entity::find()
327 .filter(member_entity::Column::UserId.eq(user_id))
328 .all(db)
329 .await?;
330 Ok(members
331 .into_iter()
332 .filter(|m| m.status == member_entity::MemberStatus::Active)
333 .map(|m| m.space_id)
334 .collect())
335}
336
337async fn effective_permissions<C>(
338 db: &C,
339 space: &space_entity::Model,
340 user_id: Uuid,
341) -> UserResult<Vec<String>>
342where
343 C: ConnectionTrait,
344{
345 if space.owner_id == user_id {
346 return Ok(OWNER_PERMISSIONS.iter().map(|s| s.to_string()).collect());
347 }
348 let member = member_entity::Entity::find_by_id((space.id, user_id)).one(db).await?;
349 match member {
350 Some(m) if m.status == member_entity::MemberStatus::Active => {}
351 _ => return Ok(Vec::new()),
352 }
353 let role_ids = member_role_entity::Entity::find()
354 .filter(member_role_entity::Column::SpaceId.eq(space.id))
355 .filter(member_role_entity::Column::UserId.eq(user_id))
356 .all(db)
357 .await?;
358 if role_ids.is_empty() {
359 return Ok(Vec::new());
360 }
361 let role_ids: Vec<Uuid> = role_ids.into_iter().map(|r| r.role_id).collect();
362 let perms = role_perm_entity::Entity::find()
363 .filter(role_perm_entity::Column::RoleId.is_in(role_ids))
364 .all(db)
365 .await?;
366 let mut granted: Vec<String> = perms.into_iter().map(|p| p.permission).collect();
367 granted.sort();
368 granted.dedup();
369 Ok(granted)
370}
371
372async fn fetch_space_views<C>(db: &C, ids: Vec<Uuid>) -> UserResult<Vec<SpaceView>>
373where
374 C: ConnectionTrait,
375{
376 if ids.is_empty() {
377 return Ok(Vec::new());
378 }
379 let models = space_entity::Entity::find()
380 .filter(space_entity::Column::Id.is_in(ids))
381 .order_by_asc(space_entity::Column::LowerName)
382 .all(db)
383 .await?;
384 Ok(models.into_iter().map(SpaceView::from).collect())
385}
386
387async fn fetch_common_spaces<C>(
388 db: &C,
389 uid: Uuid,
390 viewer: Option<Uuid>,
391) -> UserResult<Vec<SpaceView>>
392where
393 C: ConnectionTrait,
394{
395 let Some(viewer_id) = viewer else {
396 return Ok(Vec::new());
397 };
398 let mine = active_space_ids(db, viewer_id).await?;
399 let theirs = active_space_ids(db, uid).await?;
400 let ids: Vec<Uuid> = mine.intersection(&theirs).copied().collect();
401 fetch_space_views(db, ids).await
402}
403
259404fn can_view(model: &user_entity::Model, viewer: Option<Uuid>) -> bool {
260405 match model.visibility {
261406 user_entity::ProfileVisibility::Public => true,
Added lib/user/handler/profile/me_spaces.rs View lib/user/handler/profile/me_spaces.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::data::AppResponseData;
3use jwt::extractor::AuthUser;
4
5use crate::error::UserResult;
6use crate::features::UserService;
7
8use crate::features::profile::UserSpacePermissionView;
9
10#[utoipa::path(
11 get,
12 path = "/user/me/spaces",
13 tag = "user",
14 responses(
15 (status = 200, description = "Spaces the authenticated user is an active member of, with their effective permissions.", body = AppResponseData<Vec<UserSpacePermissionView>>),
16 ),
17 security(("bearer_auth" = [])),
18)]
19pub(crate) async fn handler(
20 State(svc): State<UserService>,
21 AuthUser { claims }: AuthUser,
22) -> UserResult<AppResponseData<Vec<UserSpacePermissionView>>> {
23 let spaces = svc.get_my_spaces(claims.sub).await?;
24 Ok(AppResponseData::ok(spaces))
25}
Modified lib/user/handler/profile/mod.rs View lib/user/handler/profile/mod.rs
@@ -1,4 +1,5 @@
11pub(crate) mod me;
2pub(crate) mod me_spaces;
23pub(crate) mod memes;
34pub(crate) mod profile_view;
45pub(crate) mod public_view;
Modified lib/user/handler/openapi.rs View lib/user/handler/openapi.rs
@@ -2,7 +2,8 @@
22
33use crate::features::avatar::types::{AvatarFormat, AvatarView, UploadAvatarCmd};
44use crate::features::profile::types::{
5 MemeView, ProfileView, UpdateProfileCmd, UpdateProfileFields, UpsertMemeCmd, UserMe, UserPublic,
5 MemeView, ProfileView, UpdateProfileCmd, UpdateProfileFields, UpsertMemeCmd, UserMe,
6 UserPublic, UserSpacePermissionView,
67};
78use crate::features::social::types::{
89 BlockedSummary, FollowerSummary, ListBlocksQuery, ListFollowersQuery, ListFollowingQuery,
@@ -43,6 +44,7 @@
4344 crate::handler::avatar::upload::handler,
4445 crate::handler::avatar::delete::handler,
4546 crate::handler::profile::me::handler,
47 crate::handler::profile::me_spaces::handler,
4648 crate::handler::profile::public_view::handler,
4749 crate::handler::profile::update_profile::handler,
4850 crate::handler::profile::update_admin::handler,
@@ -107,8 +109,8 @@
107109 crate::handler::notifications::scheduled_messages::cancel::handler,
108110 ),
109111 components(schemas(
110 UserPublic, UserMe, ProfileView, ProfileVisibility, UpdateProfileCmd, UpdateProfileFields,
111 MemeView, UpsertMemeCmd,
112 UserPublic, UserSpacePermissionView, UserMe, ProfileView, ProfileVisibility,
113 UpdateProfileCmd, UpdateProfileFields, MemeView, UpsertMemeCmd,
112114 AvatarView, AvatarFormat, UploadAvatarCmd,
113115 UserPreferences, UpdatePreferencesCmd, Audience,
114116 FollowerSummary, BlockedSummary, ListFollowersQuery, ListFollowingQuery, ListBlocksQuery,
Modified lib/user/handler/routes.rs View lib/user/handler/routes.rs
@@ -25,6 +25,7 @@
2525
2626 let me_protected = Router::new()
2727 .route("/me", get(super::profile::me::handler))
28 .route("/me/spaces", get(super::profile::me_spaces::handler))
2829 .route(
2930 "/me/profile",
3031 patch(super::profile::update_profile::handler),
Modified lib/user/metrics.rs View lib/user/metrics.rs
@@ -12,6 +12,7 @@
1212pub const FEATURE_PROFILE_MEMES_LIST: &str = "profile_memes_list";
1313pub const FEATURE_PROFILE_MEMES_CREATE: &str = "profile_memes_create";
1414pub const FEATURE_PROFILE_MEMES_DELETE: &str = "profile_memes_delete";
15pub const FEATURE_PROFILE_ME_SPACES: &str = "profile_me_spaces";
1516
1617pub const FEATURE_AVATAR_UPLOAD: &str = "avatar_upload";
1718pub const FEATURE_AVATAR_DELETE: &str = "avatar_delete";