Skip to content
Clone

feat(invites): add reject invite functionality for room and space invites

ZhenYi committed
parent 70df8c4 commit 5fbdb99 Browse files

14 files changed

+179 −5

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 14
Modified lib/chat/src/handlers/rooms.rs View lib/chat/src/handlers/rooms.rs
@@ -225,6 +225,31 @@
225225}
226226
227227#[utoipa::path(
228 post,
229 path = "/chat/invites/reject",
230 tag = "chat",
231 description = "受邀人拒绝邀请:邀请作废,不写入房间成员。",
232 request_body = AcceptRoomInviteCmd,
233 responses(
234 (status = 200, description = "invite rejected", body = AppResponseData<NoData>),
235 (status = 404, description = "not found", body = AppResponseData<NoData>),
236 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
237 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
238 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
239 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
240 )
241)]
242pub async fn reject_room_invite(
243 State(service): State<ChatService>,
244 session: Session,
245 Json(cmd): Json<AcceptRoomInviteCmd>,
246) -> Result<AppResponseData<NoData>, ApiError> {
247 let uid = require_uid(&session)?;
248 service.reject_room_invite(uid, &cmd.code).await?;
249 Ok(AppResponseData::empty())
250}
251
252#[utoipa::path(
228253 get,
229254 path = "/chat/rooms/{room_id}/invites",
230255 tag = "chat",
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -23,7 +23,9 @@
2323use crate::services::service::ChatService;
2424use crate::services::threads::maintain_thread_on_reply;
2525use crate::share::authz::can_manage_space;
26use crate::share::lookup::{RoomContext, require_room_readable, require_room_writable};
26use crate::share::lookup::{
27 RoomContext, require_room_readable, require_room_writable, space_slug_for,
28};
2729use crate::share::mention::parse_mentions;
2830use crate::share::notify::{
2931 NotifyDecision, advance_read_state, current_room_seq, fanout_everyone_mentions,
@@ -504,6 +506,7 @@
504506 "message_id": message_id,
505507 "seq": seq,
506508 "thread_id": cmd.thread_id,
509 "space_name": space_slug_for(&svc, space_id).await,
507510 });
508511 let body = if content.chars().count() > 120 {
509512 content.chars().take(120).collect::<String>()
@@ -836,6 +839,7 @@
836839 let data = json!({
837840 "room_id": room_id,
838841 "message_id": message_id,
842 "space_name": space_slug_for(&this_txn, space_id).await,
839843 });
840844 for &uid in &new_mentions_txn {
841845 if notification_decision(&this_txn, room_id, space_id, uid, true, false)
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -1,5 +1,6 @@
11use models::chat::message_reminders::{self, ReminderStatus};
22use models::chat::messages;
3use models::room::rooms;
34use models::users::user_notifications::NotificationType;
45use sea_orm::sea_query::Expr;
56use sea_orm::{
@@ -12,7 +13,7 @@
1213
1314use crate::errors::ChatError;
1415use crate::services::service::ChatService;
15use crate::share::lookup::require_room_readable;
16use crate::share::lookup::{require_room_readable, space_slug_for};
1617use crate::share::notify::notify_user;
1718
1819#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -213,9 +214,16 @@
213214 continue;
214215 }
215216
217 let space_id = rooms::Entity::find_by_id(reminder.room_id)
218 .one(self.db().read())
219 .await
220 .ok()
221 .flatten()
222 .and_then(|room| room.space_id);
216223 let data = serde_json::json!({
217224 "room_id": reminder.room_id,
218225 "message_id": reminder.message_id,
226 "space_name": space_slug_for(self, space_id).await,
219227 });
220228 if let Err(error) = notify_user(
221229 self.db().write(),
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -783,7 +783,16 @@
783783 .insert(self.db().write())
784784 .await?;
785785 if let Some(invitee) = cmd.invitee_id {
786 let data = serde_json::json!({"room_id": room_id, "invite_id": id});
786 // The inbox renders real accept / reject controls, so the row has to
787 // carry the same credential the invite email links: `accept_code`.
788 // `space_name` lets the client deep-link into the channel once the
789 // invitee joins.
790 let data = serde_json::json!({
791 "room_id": room_id,
792 "invite_id": id,
793 "accept_code": accept_code,
794 "space_name": space.lower_name,
795 });
787796 let pending = export::notify::PendingNotifications::default();
788797 notify_user(
789798 self.db().write(),
@@ -818,6 +827,41 @@
818827 })
819828 }
820829
830 /// The invitee declines: revoke the row so the inviter's invite list stops
831 /// reporting it as pending. Only the addressed invitee may do this, and an
832 /// already-accepted invite cannot be walked back from here.
833 pub async fn reject_room_invite(
834 &self,
835 caller: Uuid,
836 accept_code: &str,
837 ) -> Result<(), ChatError> {
838 let invite = room_invites::Entity::find()
839 .filter(room_invites::Column::AcceptCode.eq(accept_code))
840 .one(self.db().read())
841 .await?
842 .ok_or(ChatError::NotFound)?;
843 if invite.invitee_user_id != Some(caller) {
844 return Err(ChatError::Forbidden);
845 }
846 if invite.accepted_at.is_some() {
847 return Err(ChatError::Conflict("invite already accepted".to_string()));
848 }
849 if invite.revoked_at.is_some() {
850 return Ok(());
851 }
852 room_invites::Entity::update_many()
853 .col_expr(
854 room_invites::Column::RevokedAt,
855 Expr::value(Some(OffsetDateTime::now_utc())),
856 )
857 .filter(room_invites::Column::Id.eq(invite.id))
858 .filter(room_invites::Column::AcceptedAt.is_null())
859 .filter(room_invites::Column::RevokedAt.is_null())
860 .exec(self.db().write())
861 .await?;
862 Ok(())
863 }
864
821865 pub async fn list_room_invites(
822866 &self,
823867 caller: Uuid,
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -22,6 +22,19 @@
2222 }
2323}
2424
25/// Route slug (`lower_name`) of the space owning `space_id`, or `None` for a
26/// DM. Notification payloads carry it so the inbox can deep-link straight to
27/// `/app/{slug}/chat/{room}` without a second round trip.
28pub async fn space_slug_for(service: &ChatService, space_id: Option<Uuid>) -> Option<String> {
29 let space_id = space_id?;
30 spaces::Entity::find_by_id(space_id)
31 .one(service.db().read())
32 .await
33 .ok()
34 .flatten()
35 .map(|space| space.lower_name)
36}
37
2538pub async fn lookup_room(service: &ChatService, room_id: Uuid) -> Result<rooms::Model, ChatError> {
2639 rooms::Entity::find_by_id(room_id)
2740 .one(service.db().read())
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -24,6 +24,7 @@
2424 crate::handlers::room_admin::unban_room_member,
2525 crate::handlers::room_admin::transfer_room_owner,
2626 crate::handlers::rooms::accept_room_invite,
27 crate::handlers::rooms::reject_room_invite,
2728 crate::handlers::messages::send_message,
2829 crate::handlers::messages::list_messages,
2930 crate::handlers::messages::get_message,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -22,7 +22,7 @@
2222use crate::handlers::reminders::{cancel_reminder, create_reminder, list_reminders};
2323use crate::handlers::rooms::{
2424 accept_room_invite, add_room_members, create_room_invite, get_room, join_room, leave_room,
25 list_room_members, list_space_rooms, update_room,
25 list_room_members, list_space_rooms, reject_room_invite, update_room,
2626};
2727use crate::handlers::search::search_messages;
2828use crate::handlers::settings::{get_room_setting, update_room_setting};
@@ -90,6 +90,7 @@
9090 delete(crate::handlers::rooms::revoke_room_invite),
9191 )
9292 .route("/chat/invites/accept", post(accept_room_invite))
93 .route("/chat/invites/reject", post(reject_room_invite))
9394 .route(
9495 "/chat/rooms/{room_id}/messages",
9596 get(list_messages).post(send_message),
Modified lib/space/src/handlers/mod.rs View lib/space/src/handlers/mod.rs
@@ -30,6 +30,7 @@
3030pub mod list_members;
3131pub mod list_spaces;
3232pub mod move_dir_node;
33pub mod reject_invite;
3334pub mod remove_dir_group_overwrite;
3435pub mod remove_dir_user_overwrite;
3536pub mod remove_member;
Added lib/space/src/handlers/reject_invite.rs View lib/space/src/handlers/reject_invite.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Json, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::invites::AcceptInviteCmd;
7use crate::services::service::SpaceService;
8use session::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/spaces/invites/reject",
13 tag = "space",
14 description = "受邀人拒绝邀请:邀请作废,不写入空间成员。",
15 request_body = AcceptInviteCmd,
16 responses(
17 (status = 200, description = "invite rejected", body = AppResponseData<NoData>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "invite not found", body = AppResponseData<NoData>),
20 (status = 409, description = "conflict", body = AppResponseData<NoData>),
21 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
22 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn reject_invite(
26 State(service): State<SpaceService>,
27 session: Session,
28 Json(cmd): Json<AcceptInviteCmd>,
29) -> Result<AppResponseData<NoData>, ApiError> {
30 let uid = require_uid(&session)?;
31 service.reject_invite(uid, &cmd.code).await?;
32 Ok(AppResponseData::empty())
33}
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -246,8 +246,13 @@
246246 if let Some(invitee) = inserted.user_id {
247247 let data = serde_json::json!({
248248 "space_id": space.id,
249 "space_name": space.name,
249 // Routes address a space by its `lower_name`, so the inbox can
250 // link straight to it after the invitee accepts.
251 "space_name": space.lower_name,
250252 "invite_id": invite_id,
253 // Same credential the invite email carries: without it the
254 // accept button in the inbox has nothing to send.
255 "accept_code": accept_code,
251256 });
252257 let row = user_notifications::ActiveModel {
253258 id: Set(Uuid::now_v7()),
@@ -369,6 +374,36 @@
369374 })
370375 }
371376
377 /// The invitee declines: revoke the row so the inviter's invite list stops
378 /// reporting it as pending. Only the addressed invitee may do this.
379 pub async fn reject_invite(&self, caller: Uuid, accept_code: &str) -> Result<(), SpaceError> {
380 let invite = space_invite::Entity::find()
381 .filter(space_invite::Column::AcceptCode.eq(accept_code))
382 .one(self.db().read())
383 .await?
384 .ok_or(SpaceError::NotFound)?;
385 if invite.user_id != Some(caller) {
386 return Err(SpaceError::Forbidden);
387 }
388 if invite.accepted_at.is_some() {
389 return Err(SpaceError::Conflict("invite already accepted".to_string()));
390 }
391 if invite.revoked_at.is_some() {
392 return Ok(());
393 }
394 space_invite::Entity::update_many()
395 .col_expr(
396 space_invite::Column::RevokedAt,
397 Expr::value(Some(OffsetDateTime::now_utc())),
398 )
399 .filter(space_invite::Column::Id.eq(invite.id))
400 .filter(space_invite::Column::AcceptedAt.is_null())
401 .filter(space_invite::Column::RevokedAt.is_null())
402 .exec(self.db().write())
403 .await?;
404 Ok(())
405 }
406
372407 pub async fn accept_invite(
373408 &self,
374409 caller: Uuid,
Modified lib/space/src/openapi.rs View lib/space/src/openapi.rs
@@ -47,6 +47,7 @@
4747 crate::handlers::list_invites::list_invites,
4848 crate::handlers::revoke_invite::revoke_invite,
4949 crate::handlers::accept_invite::accept_invite,
50 crate::handlers::reject_invite::reject_invite,
5051 crate::handlers::ban_user::ban_user,
5152 crate::handlers::list_bans::list_bans,
5253 crate::handlers::get_ban::get_ban,
Modified lib/space/src/routes.rs View lib/space/src/routes.rs
@@ -31,6 +31,7 @@
3131use crate::handlers::list_members::list_space_members;
3232use crate::handlers::list_spaces::list_spaces;
3333use crate::handlers::move_dir_node::move_dir_node;
34use crate::handlers::reject_invite::reject_invite;
3435use crate::handlers::remove_dir_group_overwrite::remove_dir_group_overwrite;
3536use crate::handlers::remove_dir_user_overwrite::remove_dir_user_overwrite;
3637use crate::handlers::remove_member::remove_member;
@@ -59,6 +60,7 @@
5960 let protected = Router::new()
6061 .route("/spaces", post(create_space).get(list_spaces))
6162 .route("/spaces/invites/accept", post(accept_invite))
63 .route("/spaces/invites/reject", post(reject_invite))
6264 .route(
6365 "/spaces/{space_name}",
6466 patch(update_space).delete(delete_space),
Modified Cargo.lock View Cargo.lock
@@ -8968,6 +8968,11 @@
89688968]
89698969
89708970[[package]]
8971name = "web"
8972version = "0.1.0"
8973source = "git+https://gitea.gitdata.me/GitDataAi/web.git?rev=67436b2ad9#67436b2ad97b6d0ccc548cb3b1d5691711a01214"
8974
8975[[package]]
89718976name = "web-sys"
89728977version = "0.3.104"
89738978source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9010,6 +9015,7 @@
90109015 "tracing",
90119016 "user",
90129017 "utoipa",
9018 "web",
90139019]
90149020
90159021[[package]]
Modified openapi.json View openapi.json

Diff too large to display.