// Organization Branding Handlers use axum::{ Json, extract::State, http::StatusCode, }; use common::models::Organization; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; use super::handlers::{log_action, Org}; #[derive(Deserialize, Serialize)] pub struct BrandingPayload { pub name: Option, pub primary_color: Option, pub secondary_color: Option, pub platform_name: Option, pub logo_variant: Option, } #[derive(Serialize)] pub struct BrandingResponse { pub logo_url: Option, pub favicon_url: Option, pub platform_name: Option, pub logo_variant: Option, pub primary_color: String, pub secondary_color: String, } // Upload organization logo pub async fn upload_organization_logo( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { // Only admins can upload logos if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } // Verify organization exists and user has access let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; // Process multipart form while let Some(field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "file" { let filename = field .file_name() .ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))? .to_string(); // Validate file extension let ext = filename.split('.').last().unwrap_or(""); if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) { return Err(( StatusCode::BAD_REQUEST, "Invalid file type. Only PNG, JPG, and SVG allowed".into(), )); } let data = field.bytes().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file: {}", e), ) })?; // Validate file size (max 2MB) if data.len() > 2 * 1024 * 1024 { return Err(( StatusCode::BAD_REQUEST, "File too large. Maximum 2MB allowed".into(), )); } // Create uploads directory if it doesn't exist std::fs::create_dir_all("uploads/org-logos").map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create directory: {}", e), ) })?; // Generate unique filename let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-logos/{}", unique_filename); // Save file std::fs::write(&filepath, &data).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save file: {}", e), ) })?; // Update organization in database let logo_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2") .bind(&logo_url) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e), ) })?; log_action( &pool, claims.org, claims.sub, "UPDATE_LOGO", "Organization", org_ctx.id, json!({"logo_url": &logo_url}), ) .await; return Ok(Json(json!({ "logo_url": logo_url, "message": "Logo uploaded successfully" }))); } } Err((StatusCode::BAD_REQUEST, "No file provided".into())) } // Upload organization favicon pub async fn upload_organization_favicon( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { // Only admins can upload favicons if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } // Verify organization exists and user has access let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; // Process multipart form while let Some(field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "file" { let filename = field .file_name() .ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))? .to_string(); // Validate file extension let ext = filename.split('.').last().unwrap_or(""); if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) { return Err(( StatusCode::BAD_REQUEST, "Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(), )); } let data = field.bytes().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file: {}", e), ) })?; // Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe) if data.len() > 1024 * 1024 { return Err(( StatusCode::BAD_REQUEST, "File too large. Maximum 1MB allowed".into(), )); } // Create uploads directory if it doesn't exist std::fs::create_dir_all("uploads/org-favicons").map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create directory: {}", e), ) })?; // Generate unique filename let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-favicons/{}", unique_filename); // Save file std::fs::write(&filepath, &data).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save file: {}", e), ) })?; // Update organization in database let favicon_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2") .bind(&favicon_url) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e), ) })?; log_action( &pool, claims.org, claims.sub, "UPDATE_FAVICON", "Organization", org_ctx.id, json!({"favicon_url": &favicon_url}), ) .await; return Ok(Json(json!({ "favicon_url": favicon_url, "message": "Favicon uploaded successfully" }))); } } Err((StatusCode::BAD_REQUEST, "No file provided".into())) } // Update organization branding colors pub async fn update_organization_branding( claims: common::auth::Claims, Org(org_ctx): Org, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Only admins can update branding if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } // Validate hex color format let validate_color = |color: &str| -> bool { color.len() == 7 && color.starts_with('#') && color[1..].chars().all(|c| c.is_ascii_hexdigit()) }; if let Some(ref primary) = payload.primary_color { if !validate_color(primary) { return Err(( StatusCode::BAD_REQUEST, "Invalid primary_color format. Use #RRGGBB".into(), )); } } if let Some(ref secondary) = payload.secondary_color { if !validate_color(secondary) { return Err(( StatusCode::BAD_REQUEST, "Invalid secondary_color format. Use #RRGGBB".into(), )); } } // Update organization let org = sqlx::query_as::<_, Organization>( "UPDATE organizations SET name = COALESCE($1, name), primary_color = COALESCE($2, primary_color), secondary_color = COALESCE($3, secondary_color), platform_name = COALESCE($4, platform_name), logo_variant = COALESCE($5, logo_variant), updated_at = NOW() WHERE id = $6 RETURNING *", ) .bind(&payload.name) .bind(&payload.primary_color) .bind(&payload.secondary_color) .bind(&payload.platform_name) .bind(&payload.logo_variant) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e), ) })?; log_action( &pool, claims.org, claims.sub, "UPDATE_BRANDING", "Organization", org_ctx.id, json!(payload), ) .await; Ok(Json(org)) } // Get organization branding (public endpoint) pub async fn get_organization_branding( State(pool): State, ) -> Result, StatusCode> { let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(BrandingResponse { logo_url: org.logo_url, favicon_url: org.favicon_url, platform_name: org.platform_name, logo_variant: org.logo_variant, primary_color: org.primary_color.unwrap_or_else(|| "#3B82F6".to_string()), secondary_color: org.secondary_color.unwrap_or_else(|| "#8B5CF6".to_string()), })) }