feat(invites): add reject invite functionality for room and space invites
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/chat/src/handlers/rooms.rs lib/chat/src/handlers/rooms.rs +25 −0 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 lib/chat/src/services/messages.rs +5 −1 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 lib/chat/src/services/reminders.rs +9 −1 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 lib/chat/src/services/rooms.rs +45 −1 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 lib/chat/src/share/lookup.rs +13 −0 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 lib/chat/src/openapi.rs +1 −0 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 lib/chat/src/routes.rs +2 −1 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 lib/space/src/handlers/mod.rs +1 −0 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 lib/space/src/handlers/reject_invite.rs +33 −0 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 lib/space/src/services/invites.rs +36 −1 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 lib/space/src/openapi.rs +1 −0 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 lib/space/src/routes.rs +2 −0 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 Cargo.lock +6 −0 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 openapi.json View openapi.json
Diff too large to display.