Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/migrations/20251208090000_organisation_invites.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE organisation_invites (
id BIGINT PRIMARY KEY,
organisation_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
code TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
used_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
invited_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX IDX_organisation_invites_code ON organisation_invites (code);
CREATE INDEX IDX_organisation_invites_organisation_id ON organisation_invites (organisation_id);
CREATE INDEX IDX_organisation_invites_email ON organisation_invites (email);
111 changes: 111 additions & 0 deletions backend/server/src/handler/invite.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use crate::models::app::AppMessage;
use crate::models::auth::AuthUser;
use crate::models::error::ChaosError;
use crate::models::invite::Invite;
use crate::models::organisation::Organisation;
use crate::models::transaction::DBTransaction;
use axum::extract::Path;
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::query;
use std::ops::DerefMut;


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

impl InviteHandler {
/// Gets invite details for a given invite code.
///
/// # Arguments
///
/// * `transaction` - Database transaction
/// * `code` - Invite code
///
/// # Returns
///
/// * `Result<impl IntoResponse, ChaosError>` - Invite details or error
pub async fn get(
mut transaction: DBTransaction<'_>,
Path(code): Path<String>,
) -> Result<impl IntoResponse, ChaosError> {
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;
let org = query!(
"SELECT name FROM organisations WHERE id = $1",
invite.organisation_id
)
.fetch_one(transaction.tx.deref_mut())
.await?;

// Include derived booleans expected by the frontend.
let details = InviteDetails {
organisation_id: invite.organisation_id,
organisation_name: org.name,
email: invite.email,
expires_at: invite.expires_at,
used: invite.used_at.is_some(),
expired: invite.expires_at <= Utc::now(),
invited_by_user_id: invite.invited_by_user_id,
};

transaction.tx.commit().await?;
Ok(AppMessage::OkMessage(details))
}

/// Accepts an invite for the current authenticated user.
///
/// Validates the invite is not expired/used, then marks it as used.
///
/// # Arguments
///
/// * `transaction` - Database transaction
/// * `code` - Invite code
/// * `user` - Authenticated user
///
/// # Returns
///
/// * `Result<impl IntoResponse, ChaosError>` - Success message or error
pub async fn use_invite(
mut transaction: DBTransaction<'_>,
Path(code): Path<String>,
user: AuthUser,
) -> Result<impl IntoResponse, ChaosError> {
let invite = Invite::get_by_code(&code, &mut transaction.tx).await?;

// Validate the invite is not already used or expired.
if invite.used_at.is_some() {
return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string()));
}
if invite.expires_at <= Utc::now() {
return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string()));
}

// Add the user to the organisation.
Organisation::add_user(invite.organisation_id, user.user_id, &mut transaction.tx).await?;

// Mark the invite as used.
Invite::mark_used(&code, user.user_id, &mut transaction.tx).await?;

transaction.tx.commit().await?;
Ok(AppMessage::OkMessage("Invite accepted successfully"))
}


}

/// Response payload for invite details expected by the frontend.
#[derive(Serialize)]
pub struct InviteDetails {
#[serde(serialize_with = "crate::models::serde_string::serialize")]
pub organisation_id: i64,
pub organisation_name: String,
pub email: String,
pub expires_at: DateTime<Utc>,
pub used: bool,
pub expired: bool,
/// ID of the user that invited the member to the organisation
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
pub invited_by_user_id: Option<i64>
}
2 changes: 2 additions & 0 deletions backend/server/src/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! - `email_template`: Processes email template requests
//! - `offer`: Handles offer-related requests
//! - `organisation`: Processes organisation-related requests
//! - `invite`: Handles invite-related requests
//! - `question`: Handles question-related requests
//! - `rating`: Processes rating-related requests
//! - `role`: Handles role-related requests
Expand All @@ -23,6 +24,7 @@ pub mod campaign;
pub mod email_template;
pub mod offer;
pub mod organisation;
pub mod invite;
pub mod question;
pub mod rating;
pub mod role;
Expand Down
16 changes: 12 additions & 4 deletions backend/server/src/handler/organisation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,14 +359,22 @@ impl OrganisationHandler {
pub async fn invite_user(
mut transaction: DBTransaction<'_>,
Path(id): Path<i64>,
_admin: OrganisationAdmin,
State(state): State<AppState>,
admin: OrganisationAdmin,
State(mut state): State<AppState>,
Json(request_body): Json<MemberToInvite>,
) -> Result<impl IntoResponse, ChaosError> {
Organisation::invite_user(id, request_body.email, state.email_credentials, &mut transaction.tx).await?;
let invite_code = Organisation::invite_user(
id,
admin.user_id,
request_body.email,
state.email_credentials.clone(),
&mut state.snowflake_generator,
&mut transaction.tx,
)
.await?;

transaction.tx.commit().await?;
Ok(AppMessage::OkMessage("Successfully invited user to organisation"))
Ok(AppMessage::OkMessage(invite_code))
}

/// Updates an organisation's logo.
Expand Down
13 changes: 13 additions & 0 deletions backend/server/src/models/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::handler::organisation::OrganisationHandler;
use crate::handler::question::QuestionHandler;
use crate::handler::rating::RatingHandler;
use crate::handler::role::RoleHandler;
use crate::handler::invite::InviteHandler;
use crate::handler::user::UserHandler;
use crate::models::email::{ChaosEmail, EmailCredentials};
use crate::models::error::ChaosError;
Expand All @@ -28,6 +29,7 @@ use serde::Serialize;
use tower_http::cors::CorsLayer;
use crate::service::oauth2::build_oauth_client;


#[derive(Serialize)]
pub enum AppMessage<T: Serialize> {
OkMessage(T),
Expand Down Expand Up @@ -424,6 +426,17 @@ pub async fn app() -> Result<Router, ChaosError> {
"/api/v1/offer/:offer_id/send",
post(OfferHandler::send_offer),
)

// Invite routes
// - GET /api/v1/invite/:code -> invite details
// - POST /api/v1/invite/:code -> accept invite
.route(
"/api/v1/invite/:code", get(InviteHandler::get).post(InviteHandler::use_invite)
)




.layer(cors)
.with_state(state))
}
127 changes: 127 additions & 0 deletions backend/server/src/models/invite.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use crate::models::error::ChaosError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
use std::ops::DerefMut;

/// Represents an organisation invite.
#[derive(Deserialize, Serialize, sqlx::FromRow, Clone, Debug)]
pub struct Invite {
/// Unique identifier for the invite
#[serde(serialize_with = "crate::models::serde_string::serialize")]
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
pub id: i64,
/// Unique invite code (used by invite URL)
pub code: String,
/// Organisation being invited to
#[serde(serialize_with = "crate::models::serde_string::serialize")]
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
pub organisation_id: i64,
/// Email address the invite was sent to
pub email: String,
/// When the invite expires
pub expires_at: DateTime<Utc>,
/// When the invite was used (if used)
pub used_at: Option<DateTime<Utc>>,
/// User who used the invite (if used)
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
pub used_by: Option<i64>,
/// When the invite was created
pub created_at: DateTime<Utc>,
/// ID of the user that invited the member to the organisation
#[serde(serialize_with = "crate::models::serde_string::serialize_option")]
#[serde(deserialize_with = "crate::models::serde_string::deserialize_option")]
pub invited_by_user_id: Option<i64>,

}

impl Invite {
/// Fetches an invite by its code.
///
/// # Arguments
///
/// * `code` - Invite code
/// * `transaction` - Database transaction to use
///
/// # Returns
///
/// * `Result<Invite, ChaosError>` - Invite row or error
pub async fn get_by_code(
code: &str,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<Invite, ChaosError> {
let invite = sqlx::query_as!(
Invite,
r#"
SELECT
id,
code,
organisation_id,
email,
expires_at,
used_at,
used_by,
created_at,
invited_by_user_id
FROM organisation_invites
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()
"#,
code
)
.fetch_one(transaction.deref_mut())
.await?;

Ok(invite)
}

/// Marks an invite as used by a user.
///
/// # Arguments
///
/// * `code` - Invite code
/// * `user_id` - User redeeming the invite
/// * `transaction` - Database transaction to use
///
/// # Returns
///
/// * `Result<(), ChaosError>` - Ok on success
pub async fn mark_used(
code: &str,
user_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let _ = sqlx::query!(
r#"
UPDATE organisation_invites
SET used_at = $1, used_by = $2
WHERE code = $3 AND used_at IS NULL AND expires_at > NOW()
RETURNING id
"#,
Utc::now(),
user_id,
code,
)
.fetch_one(transaction.deref_mut())
.await?;

Ok(())
}

/// Deletes an invite by code.
pub async fn delete_by_code(
code: &str,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let _ = sqlx::query!(
"DELETE FROM organisation_invites WHERE code = $1 RETURNING id",
code
)
.fetch_one(transaction.deref_mut())
.await?;

Ok(())
}


}
1 change: 1 addition & 0 deletions backend/server/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod serde_string;
pub mod email;
pub mod email_template;
pub mod error;
pub mod invite;
pub mod offer;
pub mod organisation;
pub mod question;
Expand Down
Loading
Loading