|
|
|
@@ -0,0 +1,532 @@
|
|
|
|
|
// Organizations, members, and invitations routes
|
|
|
|
|
|
|
|
|
|
use crate::{
|
|
|
|
|
auth::jwt::validate_jwt,
|
|
|
|
|
db::models::organization::{Invitation, Organization, OrganizationMember},
|
|
|
|
|
error::{AppError, AppResult},
|
|
|
|
|
AppState,
|
|
|
|
|
};
|
|
|
|
|
use axum::{
|
|
|
|
|
extract::{Path, Query, State},
|
|
|
|
|
http::{HeaderMap, StatusCode},
|
|
|
|
|
routing::{delete, get, patch, post},
|
|
|
|
|
Json, Router,
|
|
|
|
|
};
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use tracing::info;
|
|
|
|
|
|
|
|
|
|
// ── Auth helper ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
|
|
|
|
|
let auth = headers
|
|
|
|
|
.get("Authorization")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.and_then(|v| v.strip_prefix("Bearer "))
|
|
|
|
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
|
|
|
|
|
|
|
|
|
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
|
|
|
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
|
|
|
|
|
|
|
|
|
let user_id: i64 = claims
|
|
|
|
|
.sub
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
|
|
|
|
|
|
|
|
|
Ok((user_id, claims.role))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if user is org admin (owner or admin role).
|
|
|
|
|
async fn require_org_admin(
|
|
|
|
|
db: &sqlx::SqlitePool,
|
|
|
|
|
org_id: i64,
|
|
|
|
|
user_id: i64,
|
|
|
|
|
) -> AppResult<()> {
|
|
|
|
|
let member: Option<OrganizationMember> = sqlx::query_as(
|
|
|
|
|
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.fetch_optional(db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
match member {
|
|
|
|
|
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => Ok(()),
|
|
|
|
|
_ => Err(AppError::Forbidden("Admin access required".into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if user is a member of an org.
|
|
|
|
|
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
|
|
|
|
|
let member: Option<OrganizationMember> = sqlx::query_as(
|
|
|
|
|
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.fetch_optional(db)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(member.is_some())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Routes ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct ListOrgsQuery {
|
|
|
|
|
limit: Option<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
|
|
|
Router::new()
|
|
|
|
|
// Organizations CRUD
|
|
|
|
|
.route("/api/organizations", get(list_organizations).post(create_organization))
|
|
|
|
|
.route("/api/organizations/plans", get(list_plans))
|
|
|
|
|
.route("/api/organizations/{org_id}", get(get_organization).patch(update_organization))
|
|
|
|
|
// Members
|
|
|
|
|
.route("/api/organizations/{org_id}/members", get(list_members))
|
|
|
|
|
.route("/api/organizations/{org_id}/members/{user_id}", patch(update_member_role).delete(remove_member))
|
|
|
|
|
// Invitations
|
|
|
|
|
.route("/api/organizations/{org_id}/invitations", get(list_invitations).post(create_invitation))
|
|
|
|
|
.route("/api/organizations/{org_id}/invitations/{inv_id}", delete(cancel_invitation))
|
|
|
|
|
// Public invitation routes (no org prefix)
|
|
|
|
|
.route("/api/invitations/{token}", get(get_invitation))
|
|
|
|
|
.route("/api/invitations/{token}/accept", post(accept_invitation))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Organization Handlers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// GET /api/organizations
|
|
|
|
|
async fn list_organizations(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Query(q): Query<ListOrgsQuery>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
|
|
|
|
|
|
|
|
|
let orgs: Vec<Organization> = sqlx::query_as(
|
|
|
|
|
r#"SELECT o.* FROM organizations o
|
|
|
|
|
INNER JOIN organization_members om ON om.org_id = o.id
|
|
|
|
|
WHERE om.user_id = ?
|
|
|
|
|
ORDER BY o.created_at DESC
|
|
|
|
|
LIMIT ?"#
|
|
|
|
|
)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "organizations": orgs })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// POST /api/organizations
|
|
|
|
|
async fn create_organization(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Json(body): Json<serde_json::Value>,
|
|
|
|
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
|
|
|
|
|
let name = body.get("name").and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
|
|
|
|
|
let slug = body.get("slug").and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
|
|
|
|
|
|
|
|
|
|
let result = sqlx::query(
|
|
|
|
|
"INSERT INTO organizations (name, slug) VALUES (?, ?)"
|
|
|
|
|
)
|
|
|
|
|
.bind(name)
|
|
|
|
|
.bind(slug)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
let msg = e.to_string();
|
|
|
|
|
if msg.contains("UNIQUE") {
|
|
|
|
|
AppError::BadRequest("Organization slug already exists".into())
|
|
|
|
|
} else {
|
|
|
|
|
AppError::Internal(anyhow::anyhow!("Failed to create organization: {e}"))
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let org_id = result.last_insert_rowid();
|
|
|
|
|
|
|
|
|
|
// Add creator as owner
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.fetch_one(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok((StatusCode::CREATED, Json(json!({ "organization": org }))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GET /api/organizations/plans
|
|
|
|
|
async fn list_plans() -> AppResult<Json<Value>> {
|
|
|
|
|
// Static plan definitions matching Node.js listAllPlans()
|
|
|
|
|
let plans = json!([
|
|
|
|
|
{
|
|
|
|
|
"id": "free",
|
|
|
|
|
"name": "免费",
|
|
|
|
|
"description": "基础功能",
|
|
|
|
|
"limits": { "members": 3, "sites": 2 },
|
|
|
|
|
"price": 0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "pro",
|
|
|
|
|
"name": "专业版",
|
|
|
|
|
"description": "进阶功能",
|
|
|
|
|
"limits": { "members": 20, "sites": 20 },
|
|
|
|
|
"price": 9900,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "enterprise",
|
|
|
|
|
"name": "企业版",
|
|
|
|
|
"description": "全功能",
|
|
|
|
|
"limits": { "members": 100, "sites": 100 },
|
|
|
|
|
"price": 49900,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
Ok(Json(json!({ "plans": plans })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GET /api/organizations/:org_id
|
|
|
|
|
async fn get_organization(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(org_id): Path<i64>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
|
|
|
|
|
if !is_org_member(&state.db, org_id, user_id).await? {
|
|
|
|
|
return Err(AppError::Forbidden("You are not a member of this organization".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.fetch_optional(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
match org {
|
|
|
|
|
Some(o) => Ok(Json(json!({ "organization": o }))),
|
|
|
|
|
None => Err(AppError::NotFound("Organization not found".into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// PATCH /api/organizations/:org_id
|
|
|
|
|
async fn update_organization(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(org_id): Path<i64>,
|
|
|
|
|
Json(body): Json<serde_json::Value>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
let name = body.get("name").and_then(|v| v.as_str());
|
|
|
|
|
let slug = body.get("slug").and_then(|v| v.as_str());
|
|
|
|
|
|
|
|
|
|
if name.is_none() && slug.is_none() {
|
|
|
|
|
return Err(AppError::BadRequest("No fields to update".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(s) = slug {
|
|
|
|
|
sqlx::query("UPDATE organizations SET slug = ?, updated_at = datetime('now') WHERE id = ?")
|
|
|
|
|
.bind(s)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
if let Some(n) = name {
|
|
|
|
|
sqlx::query("UPDATE organizations SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
|
|
|
|
.bind(n)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.fetch_one(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "organization": org })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Member Handlers ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// GET /api/organizations/:org_id/members
|
|
|
|
|
async fn list_members(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(org_id): Path<i64>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
|
|
|
|
|
if !is_org_member(&state.db, org_id, user_id).await? {
|
|
|
|
|
return Err(AppError::Forbidden("You are not a member of this organization".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
|
|
|
|
|
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.fetch_all(&*state.db)
|
|
|
|
|
.await?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|m| json!({
|
|
|
|
|
"id": m.id,
|
|
|
|
|
"orgId": m.org_id,
|
|
|
|
|
"userId": m.user_id,
|
|
|
|
|
"role": m.role,
|
|
|
|
|
"status": m.status,
|
|
|
|
|
"joinedAt": m.joined_at,
|
|
|
|
|
}))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "members": members })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// PATCH /api/organizations/:org_id/members/:user_id
|
|
|
|
|
async fn update_member_role(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
|
|
|
|
Json(body): Json<serde_json::Value>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
let role = body.get("role").and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
|
|
|
|
|
|
|
|
|
|
if !["owner", "admin", "member"].contains(&role) {
|
|
|
|
|
return Err(AppError::BadRequest("Role must be one of: owner, admin, member".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
|
|
|
|
|
.bind(role)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(target_user_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "success": true })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// DELETE /api/organizations/:org_id/members/:user_id
|
|
|
|
|
async fn remove_member(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
|
|
|
|
) -> AppResult<StatusCode> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query("DELETE FROM organization_members WHERE org_id = ? AND user_id = ?")
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(target_user_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Invitation Handlers ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// GET /api/organizations/:org_id/invitations
|
|
|
|
|
async fn list_invitations(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(org_id): Path<i64>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
let invitations: Vec<Invitation> = sqlx::query_as(
|
|
|
|
|
"SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.fetch_all(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "invitations": invitations })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// POST /api/organizations/:org_id/invitations
|
|
|
|
|
async fn create_invitation(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(org_id): Path<i64>,
|
|
|
|
|
Json(body): Json<serde_json::Value>,
|
|
|
|
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
let email = body.get("email").and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
|
|
|
|
|
let role = body.get("role").and_then(|v| v.as_str()).unwrap_or("member");
|
|
|
|
|
|
|
|
|
|
if !["admin", "member"].contains(&role) {
|
|
|
|
|
return Err(AppError::BadRequest("Role must be one of: admin, member".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate token
|
|
|
|
|
let token = format!("inv-{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
|
|
|
|
let expires_at = chrono::Utc::now()
|
|
|
|
|
.checked_add_signed(chrono::Duration::days(7))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.format("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let result = sqlx::query(
|
|
|
|
|
"INSERT INTO invitations (org_id, email, token, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
|
|
|
)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.bind(email)
|
|
|
|
|
.bind(&token)
|
|
|
|
|
.bind(role)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.bind(&expires_at)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let inv: Invitation = sqlx::query_as("SELECT * FROM invitations WHERE id = ?")
|
|
|
|
|
.bind(result.last_insert_rowid())
|
|
|
|
|
.fetch_one(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok((StatusCode::CREATED, Json(json!({ "invitation": inv }))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// DELETE /api/organizations/:org_id/invitations/:inv_id
|
|
|
|
|
async fn cancel_invitation(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path((org_id, inv_id)): Path<(i64, i64)>,
|
|
|
|
|
) -> AppResult<StatusCode> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
require_org_admin(&state.db, org_id, user_id).await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query("UPDATE invitations SET status = 'cancelled' WHERE id = ? AND org_id = ?")
|
|
|
|
|
.bind(inv_id)
|
|
|
|
|
.bind(org_id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Public Invitation Handlers ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// GET /api/invitations/:token — public, no auth required
|
|
|
|
|
async fn get_invitation(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(token): Path<String>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let invitation: Option<Invitation> = sqlx::query_as(
|
|
|
|
|
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
|
|
|
|
|
)
|
|
|
|
|
.bind(&token)
|
|
|
|
|
.fetch_optional(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
match invitation {
|
|
|
|
|
Some(i) => {
|
|
|
|
|
// Check expiry
|
|
|
|
|
let expired = i.expires_at.as_ref().map_or(false, |e| {
|
|
|
|
|
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if expired {
|
|
|
|
|
return Err(AppError::NotFound("Invitation not found or expired".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get org name
|
|
|
|
|
let org_name: Option<(String,)> = sqlx::query_as(
|
|
|
|
|
"SELECT name FROM organizations WHERE id = ?"
|
|
|
|
|
)
|
|
|
|
|
.bind(i.org_id)
|
|
|
|
|
.fetch_optional(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
"invitation": {
|
|
|
|
|
"id": i.id,
|
|
|
|
|
"orgId": i.org_id,
|
|
|
|
|
"orgName": org_name.map(|n| n.0).unwrap_or_default(),
|
|
|
|
|
"email": i.email,
|
|
|
|
|
"token": i.token,
|
|
|
|
|
"role": i.role,
|
|
|
|
|
"invitedByUsername": "",
|
|
|
|
|
"status": i.status,
|
|
|
|
|
"expiresAt": i.expires_at,
|
|
|
|
|
"createdAt": i.created_at,
|
|
|
|
|
}
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
None => Err(AppError::NotFound("Invitation not found or expired".into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// POST /api/invitations/:token/accept — JWT auth required
|
|
|
|
|
async fn accept_invitation(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Path(token): Path<String>,
|
|
|
|
|
) -> AppResult<Json<Value>> {
|
|
|
|
|
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
|
|
|
|
|
|
|
|
|
let invitation: Option<Invitation> = sqlx::query_as(
|
|
|
|
|
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
|
|
|
|
|
)
|
|
|
|
|
.bind(&token)
|
|
|
|
|
.fetch_optional(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let inv = match invitation {
|
|
|
|
|
Some(i) => i,
|
|
|
|
|
None => return Err(AppError::BadRequest("Invitation not found or expired".into())),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check expiry
|
|
|
|
|
let expired = inv.expires_at.as_ref().map_or(true, |e| {
|
|
|
|
|
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
|
|
|
|
.unwrap_or(true)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if expired {
|
|
|
|
|
return Err(AppError::BadRequest("Invitation not found or expired".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark invitation as accepted
|
|
|
|
|
sqlx::query("UPDATE invitations SET status = 'accepted' WHERE id = ?")
|
|
|
|
|
.bind(inv.id)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Add user as member
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)"
|
|
|
|
|
)
|
|
|
|
|
.bind(inv.org_id)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.bind(&inv.role)
|
|
|
|
|
.execute(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
|
|
|
|
.bind(inv.org_id)
|
|
|
|
|
.fetch_optional(&*state.db)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ "organization": org })))
|
|
|
|
|
}
|