1use axum::Json;
4use axum::extract::{Path, State};
5use axum::http::StatusCode as ReqwestStatusCode;
6use axum::response::{IntoResponse as _, Response};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::auth::{self, CurrentUser, uuid_from_bytes};
12use crate::error::Error;
13use crate::groups::{self, GroupRole, InvitationStatus};
14use crate::rate_limit::{self, RateCategory};
15use crate::state::AppState;
16
17#[derive(Debug, Deserialize)]
19pub struct CreateInvitationRequest {
20 pub identifier: String,
22 #[serde(default = "default_target_role")]
25 pub target_role: String,
26}
27
28fn default_target_role() -> String {
30 "member".to_owned()
31}
32
33#[derive(Debug, Clone, Serialize)]
36pub struct InvitationView {
37 pub invitation_id: Uuid,
39 pub group_id: Uuid,
41 pub group_name: String,
43 pub inviter_id: Uuid,
45 pub inviter_username: String,
47 pub inviter_legacy_name: String,
49 pub invitee_id: Uuid,
51 pub invitee_username: String,
53 pub invitee_legacy_name: String,
55 pub target_role: GroupRole,
57 pub status: InvitationStatus,
59 pub created_at: DateTime<Utc>,
61 pub responded_at: Option<DateTime<Utc>>,
63}
64
65#[derive(Debug, Serialize)]
67pub struct ListInvitationsResponse {
68 pub invitations: Vec<InvitationView>,
70}
71
72#[derive(Debug, Serialize)]
74pub struct InvitationResponse {
75 pub invitation: InvitationView,
77}
78
79pub async fn create(
89 user: CurrentUser,
90 State(state): State<AppState>,
91 Path(group_id): Path<Uuid>,
92 Json(req): Json<CreateInvitationRequest>,
93) -> Result<(ReqwestStatusCode, Json<InvitationResponse>), Error> {
94 rate_limit::try_acquire(&state.db, RateCategory::InvitationCreate, user.user_id).await?;
95 require_owner(&state, group_id, user.user_id).await?;
96 let identifier = req.identifier.trim();
97 if identifier.is_empty() {
98 return Err(Error::BadRequest("identifier is required".to_owned()));
99 }
100 let target_role = GroupRole::parse(req.target_role.trim())?;
101
102 let invitee_row = auth::lookup_user_by_identifier(&state.db, identifier)
103 .await?
104 .ok_or_else(|| {
105 Error::BadRequest("no user matches that identifier".to_owned())
108 })?;
109 let (invitee_bytes, invitee_legacy, invitee_username, _password_hash) = invitee_row;
110 let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
111 tracing::error!("invitee user_id blob was the wrong length");
112 Error::Database
113 })?;
114
115 if invitee_id == user.user_id {
116 return Err(Error::BadRequest(
117 "cannot invite yourself to a group you are already in".to_owned(),
118 ));
119 }
120 if let Some(existing_role) = groups::lookup_role(&state.db, group_id, invitee_id).await? {
121 return Err(Error::BadRequest(format!(
122 "user is already a {} of this group",
123 existing_role.as_str()
124 )));
125 }
126
127 let invitation_id = Uuid::new_v4();
128 let now = Utc::now();
129 let insert = sqlx::query(
130 "INSERT INTO group_invitations \
131 (invitation_id, group_id, invitee_id, inviter_id, target_role, status, created_at) \
132 VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
133 )
134 .bind(invitation_id.as_bytes().to_vec())
135 .bind(group_id.as_bytes().to_vec())
136 .bind(invitee_id.as_bytes().to_vec())
137 .bind(user.user_id.as_bytes().to_vec())
138 .bind(target_role.as_str())
139 .bind(now)
140 .execute(&state.db)
141 .await;
142 if let Err(err) = insert {
143 if let sqlx::Error::Database(db_err) = &err
145 && db_err.is_unique_violation()
146 {
147 return Err(Error::BadRequest(
148 "there is already a pending invitation for this user".to_owned(),
149 ));
150 }
151 tracing::error!("invitation insert failed: {err}");
152 return Err(Error::Database);
153 }
154
155 let inviter_username = user.username.clone();
156 let inviter_legacy_name = user.legacy_name.clone();
157 let group_name = group_name(&state, group_id).await?;
158 let view = InvitationView {
159 invitation_id,
160 group_id,
161 group_name,
162 inviter_id: user.user_id,
163 inviter_username,
164 inviter_legacy_name,
165 invitee_id,
166 invitee_username,
167 invitee_legacy_name: invitee_legacy,
168 target_role,
169 status: InvitationStatus::Pending,
170 created_at: now,
171 responded_at: None,
172 };
173 Ok((
174 ReqwestStatusCode::CREATED,
175 Json(InvitationResponse { invitation: view }),
176 ))
177}
178
179pub async fn list_for_group(
187 user: CurrentUser,
188 State(state): State<AppState>,
189 Path(group_id): Path<Uuid>,
190) -> Result<Json<ListInvitationsResponse>, Error> {
191 require_owner(&state, group_id, user.user_id).await?;
192 let invitations = fetch_invitations(&state, InvitationsFilter::Group(group_id)).await?;
193 Ok(Json(ListInvitationsResponse { invitations }))
194}
195
196pub async fn list_mine(
203 user: CurrentUser,
204 State(state): State<AppState>,
205) -> Result<Json<ListInvitationsResponse>, Error> {
206 let invitations =
207 fetch_invitations(&state, InvitationsFilter::PendingForInvitee(user.user_id)).await?;
208 Ok(Json(ListInvitationsResponse { invitations }))
209}
210
211pub async fn accept(
223 user: CurrentUser,
224 State(state): State<AppState>,
225 Path(invitation_id): Path<Uuid>,
226) -> Result<Response, Error> {
227 let row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
228 let (group_id_bytes, target_role) = row;
229 let now = Utc::now();
230 let mut tx = state.db.begin().await.map_err(|err| {
231 tracing::error!("begin accept tx failed: {err}");
232 Error::Database
233 })?;
234 sqlx::query(
235 "INSERT INTO group_memberships (group_id, user_id, role, created_at) \
236 VALUES (?1, ?2, ?3, ?4) \
237 ON CONFLICT(group_id, user_id) DO UPDATE \
238 SET role = excluded.role \
239 WHERE excluded.role = 'owner' AND group_memberships.role = 'member'",
240 )
241 .bind(&group_id_bytes)
242 .bind(user.user_id.as_bytes().to_vec())
243 .bind(target_role.as_str())
244 .bind(now)
245 .execute(&mut *tx)
246 .await
247 .map_err(|err| {
248 tracing::error!("insert membership on accept failed: {err}");
249 Error::Database
250 })?;
251 let result = sqlx::query(
252 "UPDATE group_invitations SET status = 'accepted', responded_at = ?1 \
253 WHERE invitation_id = ?2 AND status = 'pending'",
254 )
255 .bind(now)
256 .bind(invitation_id.as_bytes().to_vec())
257 .execute(&mut *tx)
258 .await
259 .map_err(|err| {
260 tracing::error!("update invitation on accept failed: {err}");
261 Error::Database
262 })?;
263 if result.rows_affected() != 1 {
264 return Err(Error::BadRequest(
265 "invitation is no longer pending".to_owned(),
266 ));
267 }
268 tx.commit().await.map_err(|err| {
269 tracing::error!("commit accept tx failed: {err}");
270 Error::Database
271 })?;
272 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
273}
274
275pub async fn reject(
284 user: CurrentUser,
285 State(state): State<AppState>,
286 Path(invitation_id): Path<Uuid>,
287) -> Result<Response, Error> {
288 let _row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
289 let now = Utc::now();
290 let result = sqlx::query(
291 "UPDATE group_invitations SET status = 'rejected', responded_at = ?1 \
292 WHERE invitation_id = ?2 AND status = 'pending'",
293 )
294 .bind(now)
295 .bind(invitation_id.as_bytes().to_vec())
296 .execute(&state.db)
297 .await
298 .map_err(|err| {
299 tracing::error!("update invitation on reject failed: {err}");
300 Error::Database
301 })?;
302 if result.rows_affected() != 1 {
303 return Err(Error::BadRequest(
304 "invitation is no longer pending".to_owned(),
305 ));
306 }
307 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
308}
309
310async fn fetch_pending_for_invitee(
317 state: &AppState,
318 invitation_id: Uuid,
319 invitee_id: Uuid,
320) -> Result<(Vec<u8>, GroupRole), Error> {
321 let row: Option<(Vec<u8>, Vec<u8>, String, String)> = sqlx::query_as(
322 "SELECT group_id, invitee_id, target_role, status FROM group_invitations \
323 WHERE invitation_id = ?1",
324 )
325 .bind(invitation_id.as_bytes().to_vec())
326 .fetch_optional(&state.db)
327 .await
328 .map_err(|err| {
329 tracing::error!("fetch invitation failed: {err}");
330 Error::Database
331 })?;
332 let (group_id_bytes, row_invitee_bytes, target_role, status) =
333 row.ok_or_else(|| Error::NotFound(format!("invitation {invitation_id}")))?;
334 let row_invitee = uuid_from_bytes(&row_invitee_bytes).ok_or_else(|| {
335 tracing::error!("bad invitee uuid in invitation");
336 Error::Database
337 })?;
338 if row_invitee != invitee_id {
339 return Err(Error::NotFound(format!("invitation {invitation_id}")));
340 }
341 if status != "pending" {
342 return Err(Error::BadRequest(format!("invitation is already {status}")));
343 }
344 let target_role = GroupRole::parse(&target_role)?;
345 Ok((group_id_bytes, target_role))
346}
347
348enum InvitationsFilter {
353 Group(Uuid),
356 PendingForInvitee(Uuid),
358}
359
360async fn fetch_invitations(
364 state: &AppState,
365 filter: InvitationsFilter,
366) -> Result<Vec<InvitationView>, Error> {
367 let (sql, blob): (&'static str, Vec<u8>) = match filter {
368 InvitationsFilter::Group(group_id) => (
369 "SELECT group_invitations.invitation_id, \
370 group_invitations.group_id, \
371 groups.name, \
372 group_invitations.inviter_id, \
373 inviter.username, inviter.legacy_name, \
374 group_invitations.invitee_id, \
375 invitee.username, invitee.legacy_name, \
376 group_invitations.target_role, \
377 group_invitations.status, \
378 group_invitations.created_at, \
379 group_invitations.responded_at \
380 FROM group_invitations \
381 JOIN groups ON groups.group_id = group_invitations.group_id \
382 JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
383 JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
384 WHERE group_invitations.group_id = ?1 \
385 ORDER BY group_invitations.created_at DESC",
386 group_id.as_bytes().to_vec(),
387 ),
388 InvitationsFilter::PendingForInvitee(user_id) => (
389 "SELECT group_invitations.invitation_id, \
390 group_invitations.group_id, \
391 groups.name, \
392 group_invitations.inviter_id, \
393 inviter.username, inviter.legacy_name, \
394 group_invitations.invitee_id, \
395 invitee.username, invitee.legacy_name, \
396 group_invitations.target_role, \
397 group_invitations.status, \
398 group_invitations.created_at, \
399 group_invitations.responded_at \
400 FROM group_invitations \
401 JOIN groups ON groups.group_id = group_invitations.group_id \
402 JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
403 JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
404 WHERE group_invitations.invitee_id = ?1 \
405 AND group_invitations.status = 'pending' \
406 ORDER BY group_invitations.created_at DESC",
407 user_id.as_bytes().to_vec(),
408 ),
409 };
410 let rows = sqlx::query_as::<
411 _,
412 (
413 Vec<u8>,
414 Vec<u8>,
415 String,
416 Vec<u8>,
417 String,
418 String,
419 Vec<u8>,
420 String,
421 String,
422 String,
423 String,
424 DateTime<Utc>,
425 Option<DateTime<Utc>>,
426 ),
427 >(sql)
428 .bind(blob)
429 .fetch_all(&state.db)
430 .await
431 .map_err(|err| {
432 tracing::error!("fetch invitations failed: {err}");
433 Error::Database
434 })?;
435 let mut out = Vec::with_capacity(rows.len());
436 for (
437 invitation_bytes,
438 group_id_bytes,
439 group_name,
440 inviter_bytes,
441 inviter_username,
442 inviter_legacy_name,
443 invitee_bytes,
444 invitee_username,
445 invitee_legacy_name,
446 target_role,
447 status,
448 created_at,
449 responded_at,
450 ) in rows
451 {
452 let invitation_id = uuid_from_bytes(&invitation_bytes).ok_or_else(|| {
453 tracing::error!("bad invitation uuid");
454 Error::Database
455 })?;
456 let group_id = uuid_from_bytes(&group_id_bytes).ok_or_else(|| {
457 tracing::error!("bad group uuid");
458 Error::Database
459 })?;
460 let inviter_id = uuid_from_bytes(&inviter_bytes).ok_or_else(|| {
461 tracing::error!("bad inviter uuid");
462 Error::Database
463 })?;
464 let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
465 tracing::error!("bad invitee uuid");
466 Error::Database
467 })?;
468 let status = match status.as_str() {
469 "pending" => InvitationStatus::Pending,
470 "accepted" => InvitationStatus::Accepted,
471 "rejected" => InvitationStatus::Rejected,
472 other => {
473 return Err(Error::BadRequest(format!(
474 "unknown invitation status `{other}`"
475 )));
476 }
477 };
478 out.push(InvitationView {
479 invitation_id,
480 group_id,
481 group_name,
482 inviter_id,
483 inviter_username,
484 inviter_legacy_name,
485 invitee_id,
486 invitee_username,
487 invitee_legacy_name,
488 target_role: GroupRole::parse(&target_role)?,
489 status,
490 created_at,
491 responded_at,
492 });
493 }
494 Ok(out)
495}
496
497async fn group_name(state: &AppState, group_id: Uuid) -> Result<String, Error> {
499 let row: Option<(String,)> = sqlx::query_as("SELECT name FROM groups WHERE group_id = ?1")
500 .bind(group_id.as_bytes().to_vec())
501 .fetch_optional(&state.db)
502 .await
503 .map_err(|err| {
504 tracing::error!("group name lookup failed: {err}");
505 Error::Database
506 })?;
507 row.map(|(n,)| n)
508 .ok_or_else(|| Error::NotFound(format!("group {group_id}")))
509}
510
511async fn require_owner(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
515 if groups::lookup_role(&state.db, group_id, user_id).await? == Some(GroupRole::Owner) {
516 Ok(())
517 } else {
518 Err(Error::NotFound(format!("group {group_id}")))
519 }
520}