use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::error::AppError; use crate::models::channels::ChannelInvitation; use crate::models::common::Role; use crate::service::ImService; use super::session::ImSession; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateInvitationParams { pub invited_user_id: Option, pub email: Option, pub role: Option, pub expires_in_hours: Option, } impl ImService { pub async fn invitation_list( &self, _ctx: &ImSession, channel_id: Uuid, ) -> Result, AppError> { sqlx::query_as::<_, ChannelInvitation>( "SELECT id, channel_id, workspace_id, invited_user_id, email, role, token_hash, \ invited_by, accepted_at, revoked_at, expires_at, created_at \ FROM channel_invitation WHERE channel_id = $1 \ AND accepted_at IS NULL AND revoked_at IS NULL \ ORDER BY created_at DESC", ) .bind(channel_id) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn invitation_create( &self, ctx: &ImSession, channel_id: Uuid, workspace_id: Uuid, params: CreateInvitationParams, ) -> Result { let now = chrono::Utc::now(); let expires_at = now + chrono::Duration::hours(params.expires_in_hours.unwrap_or(168)); let token = Uuid::now_v7().to_string(); let token_hash = format!("{:x}", Sha256::digest(token.as_bytes())); let role = params .role .as_deref() .and_then(|s| s.parse::().ok()) .filter(|r| *r != Role::Unknown) .unwrap_or(Role::Member); sqlx::query_as::<_, ChannelInvitation>( "INSERT INTO channel_invitation \ (id, channel_id, workspace_id, invited_user_id, email, role, token_hash, \ invited_by, expires_at, created_at) \ VALUES ($1, $2, $3, $4, $5, $6::role, $7, $8, $9, $10) \ RETURNING id, channel_id, workspace_id, invited_user_id, email, role, token_hash, \ invited_by, accepted_at, revoked_at, expires_at, created_at", ) .bind(Uuid::now_v7()) .bind(channel_id) .bind(workspace_id) .bind(params.invited_user_id) .bind(params.email.as_deref()) .bind(role) .bind(&token_hash) .bind(ctx.user) .bind(expires_at) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } pub async fn invitation_accept( &self, ctx: &ImSession, invitation_id: Uuid, ) -> Result { let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; let invitation = sqlx::query_as::<_, ChannelInvitation>( "UPDATE channel_invitation SET accepted_at = $1 \ WHERE id = $2 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > $1 \ RETURNING id, channel_id, workspace_id, invited_user_id, email, role, token_hash, \ invited_by, accepted_at, revoked_at, expires_at, created_at", ) .bind(now) .bind(invitation_id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO channel_member \ (id, channel_id, user_id, role, status, muted, pinned, joined_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4::role, 'active', false, false, $5, $5, $5) \ ON CONFLICT (channel_id, user_id) DO NOTHING", ) .bind(Uuid::now_v7()) .bind(invitation.channel_id) .bind(ctx.user) .bind(invitation.role.to_string()) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(invitation) } pub async fn invitation_revoke( &self, _ctx: &ImSession, invitation_id: Uuid, ) -> Result { let now = chrono::Utc::now(); sqlx::query_as::<_, ChannelInvitation>( "UPDATE channel_invitation SET revoked_at = $1 \ WHERE id = $2 AND accepted_at IS NULL AND revoked_at IS NULL \ RETURNING id, channel_id, workspace_id, invited_user_id, email, role, token_hash, \ invited_by, accepted_at, revoked_at, expires_at, created_at", ) .bind(now) .bind(invitation_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } }