feat: Organizations + Invitations routes migrated to Rust
- New migration: organizations, organization_members, invitations tables - New model: Organization, OrganizationMember, Invitation - Full CRUD: list/create/get/update orgs, members, invitations - Public invitation lookup + accept flow - JWT auth for all protected endpoints - Admin role verification for org management - 18/18 tests pass
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
-- Organizations, members, and invitations for multi-tenant support
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
|
||||||
|
ON organizations (slug);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS org_plan_idx
|
||||||
|
ON organizations (plan);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS organization_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
joined_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
|
||||||
|
ON organization_members (org_id, user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
|
||||||
|
ON organization_members (org_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
|
||||||
|
ON organization_members (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invitations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
invited_by INTEGER REFERENCES users(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
|
||||||
|
ON invitations (token);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS invitations_status_idx
|
||||||
|
ON invitations (status);
|
||||||
@@ -8,5 +8,6 @@ pub mod setting;
|
|||||||
pub mod site;
|
pub mod site;
|
||||||
pub mod site_announcement;
|
pub mod site_announcement;
|
||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
|
pub mod organization;
|
||||||
pub mod token_route;
|
pub mod token_route;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// Organization for multi-tenant setup.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Organization {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub plan: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member of an organization.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct OrganizationMember {
|
||||||
|
pub id: i64,
|
||||||
|
pub org_id: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub joined_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invitation to join an organization.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Invitation {
|
||||||
|
pub id: i64,
|
||||||
|
pub org_id: i64,
|
||||||
|
pub email: String,
|
||||||
|
pub token: String,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub invited_by: Option<i64>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod checkin;
|
|||||||
mod comfyui;
|
mod comfyui;
|
||||||
mod events;
|
mod events;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod organizations;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
@@ -21,6 +22,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
.merge(events::routes())
|
.merge(events::routes())
|
||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
|
.merge(organizations::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
.merge(user_api_keys::routes())
|
.merge(user_api_keys::routes())
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user