Skip to content

Commit 2a62f78

Browse files
peternuynPeter Nguyen
andauthored
User invite system (#643)
* Add email invitation functionality for new organisation members - Updated the organisation model to include ChaosEmail for sending invitations. - Added TODOs for future invite code generation and summary of related UI. * UI for email invite * remigrate db, invite model and handler * add another field in organisation_invite table, according to Kavika's feedback. Fix the backend accordingly. * frontend the invite page, will now show error log, for example, user already exists etc * track inviting user's id * revert api.ts * fix from passing props to using react-query * remove try and catch in the invite frontend component * fix not allow duplicate invites in the backend side * accept invite if not log in will direct to create account * invite backend fixes * invite frontend fixes --------- Co-authored-by: Peter Nguyen <z5662723@ad.unsw.edu.au>
1 parent df42929 commit 2a62f78

File tree

15 files changed

+584
-15
lines changed

15 files changed

+584
-15
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
CREATE TABLE organisation_invites (
2+
id BIGINT PRIMARY KEY,
3+
organisation_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
4+
code TEXT NOT NULL UNIQUE,
5+
email TEXT NOT NULL,
6+
expires_at TIMESTAMPTZ NOT NULL,
7+
used_at TIMESTAMPTZ,
8+
used_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
invited_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL
11+
);
12+
13+
CREATE INDEX IDX_organisation_invites_code ON organisation_invites(code);
14+
CREATE INDEX IDX_organisation_invites_organisation_id ON organisation_invites(organisation_id);
15+
CREATE INDEX IDX_organisation_invites_email ON organisation_invites(email);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use crate::models::app::AppMessage;
2+
use crate::models::auth::AuthUser;
3+
use crate::models::error::ChaosError;
4+
use crate::models::invite::Invite;
5+
use crate::models::organisation::Organisation;
6+
use crate::models::transaction::DBTransaction;
7+
use crate::models::user::User;
8+
use axum::extract::Path;
9+
use axum::response::IntoResponse;
10+
use sqlx::query;
11+
use std::ops::DerefMut;
12+
13+
14+
/// Handler for invite-related HTTP requests.
15+
pub struct InviteHandler;
16+
17+
impl InviteHandler {
18+
/// Gets invite details for a given invite code.
19+
///
20+
/// # Arguments
21+
///
22+
/// * `transaction` - Database transaction
23+
/// * `code` - Invite code
24+
///
25+
/// # Returns
26+
///
27+
/// * `Result<impl IntoResponse, ChaosError>` - Invite details or error
28+
pub async fn get(
29+
mut transaction: DBTransaction<'_>,
30+
Path(code): Path<String>,
31+
) -> Result<impl IntoResponse, ChaosError> {
32+
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;
33+
34+
transaction.tx.commit().await?;
35+
Ok(AppMessage::OkMessage(invite))
36+
}
37+
38+
/// Accepts an invite for the current authenticated user.
39+
///
40+
/// Validates the invite is not expired/used, then marks it as used.
41+
///
42+
/// # Arguments
43+
///
44+
/// * `transaction` - Database transaction
45+
/// * `code` - Invite code
46+
/// * `user` - Authenticated user
47+
///
48+
/// # Returns
49+
///
50+
/// * `Result<impl IntoResponse, ChaosError>` - Success message or error
51+
pub async fn use_invite(
52+
mut transaction: DBTransaction<'_>,
53+
Path(code): Path<String>,
54+
auth_user: AuthUser,
55+
) -> Result<impl IntoResponse, ChaosError> {
56+
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;
57+
58+
// Ensure the invite can only be accepted by the account whose email matches the invite email.
59+
// This prevents someone from accepting an invite intended for a different email address.
60+
let user = User::get(auth_user.user_id, &mut transaction.tx).await?;
61+
62+
if user.email != invite.email {
63+
return Err(ChaosError::BadRequestWithMessage(
64+
"Invite was sent for a different email address".to_string(),
65+
));
66+
}
67+
68+
// Validate the invite is not already used or expired.
69+
if invite.used {
70+
return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string()));
71+
}
72+
if invite.expired {
73+
return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string()));
74+
}
75+
76+
// Add the user to the organisation.
77+
Organisation::add_user(invite.organisation_id, auth_user.user_id, &mut transaction.tx).await?;
78+
79+
// Mark the invite as used.
80+
Invite::mark_used(&code, auth_user.user_id, &mut transaction.tx).await?;
81+
82+
transaction.tx.commit().await?;
83+
Ok(AppMessage::OkMessage("Invite accepted successfully"))
84+
}
85+
86+
87+
}

backend/server/src/handler/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! - `email_template`: Processes email template requests
1212
//! - `offer`: Handles offer-related requests
1313
//! - `organisation`: Processes organisation-related requests
14+
//! - `invite`: Handles invite-related requests
1415
//! - `question`: Handles question-related requests
1516
//! - `rating`: Processes rating-related requests
1617
//! - `role`: Handles role-related requests
@@ -23,6 +24,7 @@ pub mod campaign;
2324
pub mod email_template;
2425
pub mod offer;
2526
pub mod organisation;
27+
pub mod invite;
2628
pub mod question;
2729
pub mod rating;
2830
pub mod role;

backend/server/src/handler/organisation.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,14 +359,22 @@ impl OrganisationHandler {
359359
pub async fn invite_user(
360360
mut transaction: DBTransaction<'_>,
361361
Path(id): Path<i64>,
362-
_admin: OrganisationAdmin,
363-
State(state): State<AppState>,
362+
admin: OrganisationAdmin,
363+
State(mut state): State<AppState>,
364364
Json(request_body): Json<MemberToInvite>,
365365
) -> Result<impl IntoResponse, ChaosError> {
366-
Organisation::invite_user(id, request_body.email, state.email_credentials, &mut transaction.tx).await?;
366+
let invite_code = Organisation::invite_user(
367+
id,
368+
admin.user_id,
369+
request_body.email,
370+
state.email_credentials.clone(),
371+
&mut state.snowflake_generator,
372+
&mut transaction.tx,
373+
)
374+
.await?;
367375

368376
transaction.tx.commit().await?;
369-
Ok(AppMessage::OkMessage("Successfully invited user to organisation"))
377+
Ok(AppMessage::OkMessage(invite_code))
370378
}
371379

372380
/// Updates an organisation's logo.

backend/server/src/models/app.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::handler::organisation::OrganisationHandler;
88
use crate::handler::question::QuestionHandler;
99
use crate::handler::rating::RatingHandler;
1010
use crate::handler::role::RoleHandler;
11+
use crate::handler::invite::InviteHandler;
1112
use crate::handler::user::UserHandler;
1213
use crate::models::email::{ChaosEmail, EmailCredentials};
1314
use crate::models::error::ChaosError;
@@ -28,6 +29,7 @@ use serde::Serialize;
2829
use tower_http::cors::CorsLayer;
2930
use crate::service::oauth2::build_oauth_client;
3031

32+
3133
#[derive(Serialize)]
3234
pub enum AppMessage<T: Serialize> {
3335
OkMessage(T),
@@ -437,6 +439,17 @@ pub async fn app() -> Result<Router, ChaosError> {
437439
"/api/v1/offer/:offer_id/send",
438440
post(OfferHandler::send_offer),
439441
)
442+
443+
// Invite routes
444+
// - GET /api/v1/invite/:code -> invite details
445+
// - POST /api/v1/invite/:code -> accept invite
446+
.route(
447+
"/api/v1/invite/:code", get(InviteHandler::get).post(InviteHandler::use_invite)
448+
)
449+
450+
451+
452+
440453
.layer(cors)
441454
.with_state(state))
442455
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use crate::models::error::ChaosError;
2+
use chrono::{DateTime, Utc};
3+
use serde::{Deserialize, Serialize};
4+
use sqlx::{Postgres, Transaction};
5+
use std::ops::DerefMut;
6+
7+
/// Represents an organisation invite.
8+
#[derive(Deserialize, Serialize, sqlx::FromRow, Clone, Debug)]
9+
pub struct Invite {
10+
/// Unique identifier for the invite
11+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
12+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
13+
pub id: i64,
14+
/// Unique invite code (used by invite URL)
15+
pub code: String,
16+
/// Organisation being invited to
17+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
18+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
19+
pub organisation_id: i64,
20+
/// Email address the invite was sent to
21+
pub email: String,
22+
/// When the invite expires
23+
pub expires_at: DateTime<Utc>,
24+
/// When the invite was used (if used)
25+
pub used_at: Option<DateTime<Utc>>,
26+
/// User who used the invite (if used)
27+
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
28+
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
29+
pub used_by: Option<i64>,
30+
/// When the invite was created
31+
pub created_at: DateTime<Utc>,
32+
/// ID of the user that invited the member to the organisation
33+
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
34+
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
35+
pub invited_by_user_id: Option<i64>,
36+
}
37+
38+
/// Response payload for invite details expected by the frontend.
39+
#[derive(Serialize)]
40+
pub struct InviteDetails {
41+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
42+
pub id: i64,
43+
pub code: String,
44+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
45+
pub organisation_id: i64,
46+
pub organisation_name: String,
47+
pub email: String,
48+
pub expires_at: DateTime<Utc>,
49+
pub used: bool,
50+
pub expired: bool,
51+
/// ID of the user that invited the member to the organisation
52+
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
53+
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
54+
pub invited_by_user_id: Option<i64>
55+
}
56+
57+
impl Invite {
58+
/// Fetches an invite by its code.
59+
///
60+
/// # Arguments
61+
///
62+
/// * `code` - Invite code
63+
/// * `transaction` - Database transaction to use
64+
///
65+
/// # Returns
66+
///
67+
/// * `Result<Invite, ChaosError>` - Invite row or error
68+
pub async fn get_by_code(
69+
code: &str,
70+
transaction: &mut Transaction<'_, Postgres>,
71+
) -> Result<InviteDetails, ChaosError> {
72+
let invite = sqlx::query_as!(
73+
InviteDetails,
74+
r#"
75+
SELECT
76+
oi.id,
77+
code,
78+
organisation_id,
79+
o.name AS organisation_name,
80+
email,
81+
expires_at,
82+
used_at IS NOT NULL AS "used!: bool",
83+
expires_at <= NOW() AS "expired!: bool",
84+
invited_by_user_id
85+
FROM organisation_invites oi
86+
JOIN organisations o ON o.id = oi.organisation_id
87+
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()
88+
"#,
89+
code
90+
)
91+
.fetch_one(transaction.deref_mut())
92+
.await?;
93+
94+
Ok(invite)
95+
}
96+
97+
/// Marks an invite as used by a user.
98+
///
99+
/// # Arguments
100+
///
101+
/// * `code` - Invite code
102+
/// * `user_id` - User redeeming the invite
103+
/// * `transaction` - Database transaction to use
104+
///
105+
/// # Returns
106+
///
107+
/// * `Result<(), ChaosError>` - Ok on success
108+
pub async fn mark_used(
109+
code: &str,
110+
user_id: i64,
111+
transaction: &mut Transaction<'_, Postgres>,
112+
) -> Result<(), ChaosError> {
113+
let _ = sqlx::query!(
114+
r#"
115+
UPDATE organisation_invites
116+
SET used_at = $1, used_by = $2
117+
WHERE code = $3 AND used_at IS NULL AND expires_at > NOW()
118+
RETURNING id
119+
"#,
120+
Utc::now(),
121+
user_id,
122+
code,
123+
)
124+
.fetch_one(transaction.deref_mut())
125+
.await?;
126+
127+
Ok(())
128+
}
129+
130+
/// Deletes an invite by code.
131+
pub async fn delete_by_code(
132+
code: &str,
133+
transaction: &mut Transaction<'_, Postgres>,
134+
) -> Result<(), ChaosError> {
135+
let _ = sqlx::query!(
136+
"DELETE FROM organisation_invites WHERE code = $1 RETURNING id",
137+
code
138+
)
139+
.fetch_one(transaction.deref_mut())
140+
.await?;
141+
142+
Ok(())
143+
}
144+
145+
146+
}

backend/server/src/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod serde_string;
1616
pub mod email;
1717
pub mod email_template;
1818
pub mod error;
19+
pub mod invite;
1920
pub mod offer;
2021
pub mod organisation;
2122
pub mod question;

0 commit comments

Comments
 (0)