Skip to content

Commit 19259bc

Browse files
author
Peter Nguyen
committed
add another field in organisation_invite table, according to Kavika's feedback. Fix the backend accordingly.
1 parent b429e36 commit 19259bc

9 files changed

Lines changed: 284 additions & 118 deletions

File tree

backend/migrations/20251208090000_organisation_invites.sql

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ CREATE TABLE organisation_invites (
66
expires_at TIMESTAMPTZ NOT NULL,
77
used_at TIMESTAMPTZ,
88
used_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
9-
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
invited_by_organisation_id BIGINT REFERENCES organisations(id) ON DELETE SET NULL
1011
);
1112

12-
CREATE INDEX IDX_organisation_invites_org_email ON organisation_invites (organisation_id, email);
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: 77 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,111 @@
1+
use crate::models::app::AppMessage;
2+
use crate::models::auth::AuthUser;
13
use crate::models::error::ChaosError;
2-
use sqlx::Postgres;
3-
use chrono::Utc;
44
use crate::models::invite::Invite;
5+
use crate::models::organisation::Organisation;
56
use crate::models::transaction::DBTransaction;
7+
use axum::extract::Path;
68
use axum::response::IntoResponse;
9+
use chrono::{DateTime, Utc};
10+
use serde::Serialize;
11+
use sqlx::query;
12+
use std::ops::DerefMut;
713

814

915
/// Handler for invite-related HTTP requests.
1016
pub struct InviteHandler;
1117

1218
impl InviteHandler {
13-
/// Validates whether an invite code is still valid (i.e. not expired).
19+
/// Gets invite details for a given invite code.
1420
///
1521
/// # Arguments
1622
///
17-
/// * `code` - The invite code to validate
18-
/// * `transaction` - Database transaction to use
23+
/// * `transaction` - Database transaction
24+
/// * `code` - Invite code
1925
///
2026
/// # Returns
2127
///
22-
/// * `Result<Invite, ChaosError>` - `Ok(invite)` if valid, `Err` if lookup fails
23-
pub async fn validate_invite_is_valid(
24-
code: &str,
25-
transaction: &mut Transaction<'_, Postgres>,
26-
) -> Result<Invite, ChaosError> {
27-
let invite = Invite::get(code, transaction).await?;
28-
// check if the invite has already been used
29-
if invite.used_at.is_some() {
30-
return Err(ChaosError::BadRequestWithMessage(
31-
"Invite already used".to_string(),
32-
));
33-
}
34-
// check if the invite has expired
35-
if invite.expires_at <= Utc::now() {
36-
return Err(ChaosError::BadRequestWithMessage(
37-
"Invite expired".to_string(),
38-
));
39-
}
40-
Ok(invite)
28+
/// * `Result<impl IntoResponse, ChaosError>` - Invite details or error
29+
pub async fn get(
30+
mut transaction: DBTransaction<'_>,
31+
Path(code): Path<String>,
32+
) -> Result<impl IntoResponse, ChaosError> {
33+
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;
34+
let org = query!(
35+
"SELECT name FROM organisations WHERE id = $1",
36+
invite.organisation_id
37+
)
38+
.fetch_one(transaction.tx.deref_mut())
39+
.await?;
40+
41+
// Include derived booleans expected by the frontend.
42+
let details = InviteDetails {
43+
organisation_id: invite.organisation_id,
44+
organisation_name: org.name,
45+
email: invite.email,
46+
expires_at: invite.expires_at,
47+
used: invite.used_at.is_some(),
48+
expired: invite.expires_at <= Utc::now(),
49+
invited_by_organisation_id: invite.invited_by_organisation_id,
50+
};
51+
52+
transaction.tx.commit().await?;
53+
Ok(AppMessage::OkMessage(details))
4154
}
42-
4355

44-
/// Uses an invite code for a user.
56+
/// Accepts an invite for the current authenticated user.
4557
///
46-
/// Validates the invite, marks it as used by the given user
58+
/// Validates the invite is not expired/used, then marks it as used.
4759
///
4860
/// # Arguments
4961
///
50-
/// * `code` - The invite code being redeemed
51-
/// * `user_id` - The ID of the user redeeming the invite
52-
/// * `transaction` - Database transaction to use
62+
/// * `transaction` - Database transaction
63+
/// * `code` - Invite code
64+
/// * `user` - Authenticated user
5365
///
5466
/// # Returns
5567
///
56-
/// * `Result<impl IntoResponse, ChaosError>` - `Ok(())` if the invite was used; `Err` if the invite is invalid
68+
/// * `Result<impl IntoResponse, ChaosError>` - Success message or error
5769
pub async fn use_invite(
58-
code: &str,
59-
user_id: i64,
60-
transaction: &mut Transaction<'_, Postgres>,
70+
mut transaction: DBTransaction<'_>,
71+
Path(code): Path<String>,
72+
user: AuthUser,
6173
) -> Result<impl IntoResponse, ChaosError> {
74+
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;
6275

63-
let invite = Self::validate_invite_is_valid(code, transaction).await?;
76+
// Validate the invite is not already used or expired.
77+
if invite.used_at.is_some() {
78+
return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string()));
79+
}
80+
if invite.expires_at <= Utc::now() {
81+
return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string()));
82+
}
6483

65-
Invite::save_used_by_person(user_id, &invite, transaction).await?;
84+
// Add the user to the organisation.
85+
Organisation::add_user(invite.organisation_id, user.user_id, &mut transaction.tx).await?;
6686

67-
transaction.tx.commit().await?;
87+
// Mark the invite as used.
88+
Invite::mark_used(&code, user.user_id, invite.invited_by_organisation_id, &mut transaction.tx).await?;
6889

69-
Ok((StatusCode::OK, AppMessage::OkMessage("Invite used successfully")))
90+
transaction.tx.commit().await?;
91+
Ok(AppMessage::OkMessage("Invite accepted successfully"))
7092
}
7193

72-
pub async fn get_code_by_id(
73-
invite_id: i64,
74-
transaction: &mut DBTransaction<'_>,
75-
) -> Result<String, ChaosError> {
76-
let invite = Invite::get(invite_id, &mut transaction.tx).await?;
77-
Ok(invite.code)
78-
}
94+
95+
}
96+
97+
/// Response payload for invite details expected by the frontend.
98+
#[derive(Serialize)]
99+
pub struct InviteDetails {
100+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
101+
pub organisation_id: i64,
102+
pub organisation_name: String,
103+
pub email: String,
104+
pub expires_at: DateTime<Utc>,
105+
pub used: bool,
106+
pub expired: bool,
107+
/// ID of the organisation that invited the user
108+
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
109+
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
110+
pub invited_by_organisation_id: Option<i64>
79111
}

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: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,20 @@ impl OrganisationHandler {
360360
mut transaction: DBTransaction<'_>,
361361
Path(id): Path<i64>,
362362
_admin: OrganisationAdmin,
363-
State(state): State<AppState>,
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+
request_body.email,
369+
state.email_credentials.clone(),
370+
&mut state.snowflake_generator,
371+
&mut transaction.tx,
372+
)
373+
.await?;
367374

368375
transaction.tx.commit().await?;
369-
Ok(AppMessage::OkMessage("Successfully invited user to organisation"))
376+
Ok(AppMessage::OkMessage(invite_code))
370377
}
371378

372379
/// Updates an organisation's logo.

backend/server/src/models/app.rs

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -428,28 +428,13 @@ pub async fn app() -> Result<Router, ChaosError> {
428428
)
429429

430430
// Invite routes
431+
// - GET /api/v1/invite/:code -> invite details
432+
// - POST /api/v1/invite/:code -> accept invite
431433
.route(
432-
"/api/v1/invite/:invite_id",
433-
get(InviteHandler::get),
434-
)
435-
.route(
436-
"/api/v1/invite/:invite_id/delete",
437-
delete(InviteHandler::delete),
438-
)
439-
.route(
440-
"/api/v1/invite/:invite_id/create",
441-
post(InviteHandler::create),
442-
)
443-
444-
.route(
445-
"/api/v1/invite/:invite_id/use",
446-
post(InviteHandler::use_invite),
434+
"/api/v1/invite/:code", get(InviteHandler::get).post(InviteHandler::use_invite)
447435
)
448436

449-
.route(
450-
"/api/v1/invite/:invite_id/get_code",
451-
get(InviteHandler::get_code_by_id),
452-
)
437+
453438

454439

455440
.layer(cors)

0 commit comments

Comments
 (0)