//! Project API: create, update, delete. use axum::{ Form, Json, extract::{Path, State}, http::header::HeaderMap, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use crate::config::Config; use crate::{AppStorage, Integrations}; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility}, error::{AppError, Result, ResultExt}, helpers::{htmx_toast_response, is_htmx_request}, types::ListResponse, validation, }; use super::verify_project_ownership; use crate::extractors::{ValidatedForm, ValidatedJson}; // Project API /// Form input for creating a new project. #[derive(Debug, Deserialize)] pub(super) struct CreateProjectRequest { pub slug: Slug, pub title: String, pub description: Option, #[serde(default)] pub features: Vec, pub category: Option, } /// JSON response representing a project. #[derive(Debug, Serialize)] pub(super) struct ProjectResponse { pub id: ProjectId, pub slug: String, pub title: String, pub description: Option, pub project_type: ProjectType, pub features: Vec, pub is_public: bool, } /// Create a new project for the authenticated creator. #[tracing::instrument(skip_all, name = "projects::create_project")] pub(super) async fn create_project( State(db): State, State(integrations): State, headers: HeaderMap, AuthUser(user): AuthUser, ValidatedForm(req): ValidatedForm, ) -> Result { user.check_not_suspended()?; // Gate: only creators can create projects if !user.can_create_projects { return Err(AppError::Forbidden); } // Validate input (slug is validated by Slug's Deserialize impl) validation::validate_project_title(&req.title)?; if let Some(ref desc) = req.description { validation::validate_project_description(desc)?; } // Resolve category if provided let category_id = if let Some(ref cat_name) = req.category { let trimmed = cat_name.trim(); if trimmed.is_empty() { None } else { let cat = db::categories::get_or_create_category(&db, trimmed).await?; Some(cat.id) } } else { None }; // Validate feature values for f in &req.features { f.parse::() .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?; } let project = db::projects::create_project( &db, user.id, &req.slug, &req.title, req.description.as_deref(), &req.features, ) .await?; // Set category if resolved if let Some(cat_id) = category_id { db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?; } // Create default mailing lists (non-blocking) if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &req.title).await { tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists"); } db::users::bump_cache_generation(&db, user.id).await?; db::projects::bump_cache_generation(&db, project.id).await?; // Fire-and-forget: provision a paired MT community if let Some(ref mt) = integrations.mt_client { let mt = mt.clone(); let db = db.clone(); let project_id = project.id; let slug = project.slug.to_string(); let title = project.title.clone(); let desc = project.description.clone(); let username = user.username.to_string(); let display_name = user.display_name.clone(); let user_id = user.id; tokio::spawn(async move { match mt .create_community(&crate::mt_client::CreateCommunityRequest { name: title, slug, description: desc, owner_mnw_id: *user_id, owner_username: username, owner_display_name: display_name, }) .await { Ok(resp) => { if let Err(e) = db::projects::set_mt_community_id(&db, project_id, resp.community_id).await { tracing::warn!(error = ?e, "failed to store MT community ID"); } } Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"), } }); } if is_htmx_request(&headers) { // Return HX-Redirect header to redirect to the project dashboard let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/project/{}", project.slug) .parse() .expect("static redirect path is valid"), ); return Ok(response); } Ok(Json(ProjectResponse { id: project.id, slug: project.slug.to_string(), title: project.title, description: project.description, project_type: project.project_type, features: project.features, is_public: project.is_public, }) .into_response()) } /// List all projects for the authenticated user. #[tracing::instrument(skip_all, name = "projects::list_projects")] pub(super) async fn list_projects( State(db): State, AuthUser(user): AuthUser, ) -> Result { let projects = db::projects::get_projects_by_user(&db, user.id).await?; let data: Vec = projects .into_iter() .map(|p| ProjectResponse { id: p.id, slug: p.slug.to_string(), title: p.title, description: p.description, project_type: p.project_type, features: p.features, is_public: p.is_public, }) .collect(); Ok(Json(ListResponse { data })) } /// JSON input for updating an existing project. #[derive(Debug, Deserialize)] pub(super) struct UpdateProjectRequest { pub title: Option, pub description: Option, pub features: Option>, pub is_public: Option, pub category: Option, /// Pricing model as kebab string: "free" | "buy_once" | "pwyw" | "subscription". pub pricing_model: Option, /// Buy-once price in dollars. Required when pricing_model="buy_once". pub price_dollars: Option, /// PWYW minimum in dollars. Optional when pricing_model="pwyw". pub pwyw_min_dollars: Option, } /// Update an existing project owned by the authenticated user. #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))] pub(super) async fn update_project( State(db): State, AuthUser(user): AuthUser, Path(id): Path, ValidatedJson(req): ValidatedJson, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&id)); user.check_not_suspended()?; verify_project_ownership(&db, id, user.id).await?; // Validate input (same rules as create_project, but all fields are optional) if let Some(ref title) = req.title { validation::validate_project_title(title)?; } if let Some(ref desc) = req.description { validation::validate_project_description(desc)?; } // Resolve category if provided if let Some(ref cat_name) = req.category { let trimmed = cat_name.trim(); if trimmed.is_empty() { db::projects::set_project_category(&db, id, user.id, None).await?; } else { let cat = db::categories::get_or_create_category(&db, trimmed).await?; db::projects::set_project_category(&db, id, user.id, Some(cat.id)).await?; } } // Validate feature values if provided if let Some(ref features) = req.features { for f in features { f.parse::() .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?; } } let updated = db::projects::update_project( &db, id, user.id, req.title.as_deref(), req.description.as_deref(), req.features.as_deref(), req.is_public, ) .await?; if let Some(ref model_str) = req.pricing_model { let kind: db::PricingKind = model_str .parse() .map_err(|_| AppError::validation(format!("Invalid pricing_model: {model_str}")))?; let price_cents = if kind == db::PricingKind::BuyOnce { let dollars = req .price_dollars .ok_or_else(|| AppError::validation("price_dollars required for buy_once"))?; // Reject NaN/Inf/negative/overflow before the cast, the raw // `(dollars * 100.0).round() as i32` form silently turns NaN into 0 // and saturates large values to i32::MAX. let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?; // `buy_once` enforces the cap, non-negative, and Stripe's minimum // charge for the creator's settlement currency, in one place shared // with the wizard writer. update_project_pricing takes PriceCents, // so a bare i32 can't reach the DB (Run 11 UX F1). db::PriceCents::buy_once(cents, user.settlement_currency)? } else { db::PriceCents::ZERO }; let pwyw_min_cents = if kind == db::PricingKind::Pwyw { let dollars = req.pwyw_min_dollars.unwrap_or(0.0); let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?; Some(db::PriceCents::new(cents)?) } else { None }; db::projects::update_project_pricing(&db, id, user.id, kind, price_cents, pwyw_min_cents) .await?; } db::projects::bump_cache_generation(&db, id).await?; Ok(Json(ProjectResponse { id: updated.id, slug: updated.slug.to_string(), title: updated.title, description: updated.description, project_type: updated.project_type, features: updated.features, is_public: updated.is_public, })) } /// Form input for choosing a project's creator theme (Tier 0). #[derive(Debug, Deserialize)] pub(super) struct UpdateProjectThemeRequest { /// Built-in theme id. Empty or absent clears to the platform default. pub theme_id: Option, } /// Set a project's creator theme. Applies to the project's public page and its /// items (which inherit it). Bumps the project cache generation so cached pages /// re-render with the new palette. #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))] pub(super) async fn update_project_theme( State(db): State, AuthUser(user): AuthUser, Path(id): Path, Form(req): Form, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&id)); user.check_not_suspended()?; verify_project_ownership(&db, id, user.id).await?; let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref()) .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?; db::projects::set_project_theme(&db, id, user.id, theme_id.as_deref()).await?; db::projects::bump_cache_generation(&db, id).await?; Ok(htmx_toast_response("Theme saved", "success")) } /// Delete a project owned by the authenticated user. /// /// Before deleting, enqueues all S3 keys (item files, version files, project /// cover image) for durable deletion and decrements the user's storage counter. #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))] pub(super) async fn delete_project( State(db): State, State(config): State, State(storage): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&id)); user.check_not_suspended()?; let project = verify_project_ownership(&db, id, user.id).await?; // Collect all S3 keys from items + versions + galleries before CASCADE // delete destroys them. Gallery rows (item_images / project_images) cascade // away too, so their keys must be swept here or they orphan with no durable // record (Run #18 Storage B2). let item_keys = db::items::get_project_item_s3_keys(&db, id).await?; let version_keys = db::items::get_project_version_s3_keys(&db, id).await?; let gallery_keys = db::gallery_images::s3_keys_for_project(&db, id).await?; let mut all_keys: Vec<(String, String)> = Vec::new(); // Version downloads are gated media, always the private bucket. all_keys.extend( version_keys .into_iter() .map(|k| (k, crate::storage::S3Bucket::Main.as_str().to_string())), ); // Item keys (audio/video are private; the item cover is public) and gallery // images (public) are content-image-or-staging keys of unknown promote state. // Enqueue each under BOTH buckets; the reaper no-ops the bucket the object // isn't in (audio/video's public row and a promoted cover's main row are // harmless no-ops). See `both_bucket_delete`. for k in item_keys.into_iter().chain(gallery_keys) { all_keys.extend(crate::storage::both_bucket_delete(&k)); } // Include the project cover image if present (public bucket, or staging in main). if let Some(ref url) = project.cover_image_url && let Some(key) = crate::storage::extract_s3_key_from_url( url, &config.cdn_base_url, storage .s3 .as_deref() .map(crate::storage::StorageBackend::bucket), config.storage.as_ref().map(|c| c.endpoint.as_str()), ) { all_keys.extend(crate::storage::both_bucket_delete(&key)); } // Enqueue for durable S3 deletion (survives crashes) BEFORE the CASCADE delete. // Abort on failure rather than warn-and-proceed: enqueue is the sole durable // deletion path for the whole project's item/version/gallery/cover keys, so // deleting the rows anyway would orphan every object with no record (ultra-fuzz // Run 12 Storage F3 sibling). The reverse case (enqueue succeeds, delete fails) // is safe, the reaper's is_s3_key_live guard skips keys whose rows still exist. db::pending_s3_deletions::enqueue_deletions(&db, &all_keys, "project_delete").await?; // Decrement storage before deleting rows let storage_bytes = db::items::get_project_storage_bytes(&db, id).await?; if storage_bytes > 0 && let Err(e) = db::creator_tiers::decrement_storage_used(&db, user.id, storage_bytes).await { tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete"); } db::projects::delete_project(&db, id, user.id).await?; db::users::bump_cache_generation(&db, user.id).await?; Ok(htmx_toast_response("Project deleted", "success")) } // Git Repo Linking /// JSON input for linking a repo to a project. #[derive(Debug, Deserialize)] pub(super) struct LinkRepoRequest { pub name: String, } /// Link a git repo to a project. The repo must exist and be owned by the same user. #[tracing::instrument(skip_all, name = "projects::link_repo")] pub(super) async fn link_repo( State(db): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&db, id, user.id).await?; let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &req.name) .await? .ok_or(AppError::validation("Repository not found".to_string()))?; db::git_repos::link_repo_to_project(&db, repo.id, id).await?; db::projects::bump_cache_generation(&db, id).await?; Ok(htmx_toast_response("Repository linked", "success")) } /// Unlink a git repo from a project. The repo must be owned by the same user. #[tracing::instrument(skip_all, name = "projects::unlink_repo")] pub(super) async fn unlink_repo( State(db): State, AuthUser(user): AuthUser, Path((id, repo_name)): Path<(ProjectId, String)>, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&db, id, user.id).await?; let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &repo_name) .await? .ok_or(AppError::validation("Repository not found".to_string()))?; db::git_repos::unlink_repo_from_project(&db, repo.id).await?; db::projects::bump_cache_generation(&db, id).await?; Ok(htmx_toast_response("Repository unlinked", "success")) } // Git Repo Creation + Visibility /// JSON input for creating a bare repo on disk. #[derive(Debug, Deserialize)] pub(super) struct CreateRepoRequest { pub name: String, pub visibility: Option, } /// JSON response representing a git repo. #[derive(Debug, Serialize)] pub(super) struct RepoResponse { pub id: GitRepoId, pub name: String, pub visibility: Visibility, } /// Create a bare git repo on disk and register it in the DB. #[tracing::instrument(skip_all, name = "projects::create_repo")] pub(super) async fn create_repo( State(db): State, State(config): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules) let name = req.name.trim(); if name.is_empty() || name.len() > 64 { return Err(AppError::validation( "Repository name must be 1-64 characters".to_string(), )); } if name.starts_with('.') || name == ".." { return Err(AppError::validation("Invalid repository name".to_string())); } if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(AppError::validation( "Repository name may only contain letters, numbers, hyphens, underscores, and dots" .to_string(), )); } // Validate visibility (enum deserialization handles validation) let visibility = req.visibility.unwrap_or(Visibility::Public); // Need git_repos_path configured let git_root = config.build.git_repos_path.as_deref().ok_or_else(|| { AppError::validation("Git repositories are not configured on this server".to_string()) })?; // Check repo doesn't already exist in DB if db::git_repos::get_repo_by_user_and_name(&db, user.id, name) .await? .is_some() { return Err(AppError::validation( "A repository with that name already exists".to_string(), )); } // Create bare repo on disk: {git_root}/{username}/{name}.git let username = user.username.to_string(); let owner_dir = std::path::Path::new(git_root).join(&username); let repo_dir = owner_dir.join(format!("{name}.git")); if repo_dir.exists() { return Err(AppError::validation( "A repository with that name already exists on disk".to_string(), )); } std::fs::create_dir_all(&owner_dir).context("create git owner directory")?; crate::git::init_bare_repo(&repo_dir).context("init bare git repo")?; // Install post-receive hook if build triggers are configured if let Some(token) = &config.build.trigger_token { let hooks_dir = repo_dir.join("hooks"); let hook_path = hooks_dir.join("post-receive"); let hook_content = crate::build_runner::post_receive_hook(token, &username, name); if let Err(e) = std::fs::write(&hook_path, &hook_content) { tracing::warn!(error = ?e, "failed to install post-receive hook"); } else { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)); } } } // Register in DB let db_repo = db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?; Ok(Json(RepoResponse { id: db_repo.id, name: db_repo.name, visibility: db_repo.visibility, })) } /// JSON input for updating repo visibility. #[derive(Debug, Deserialize)] pub(super) struct UpdateRepoVisibilityRequest { pub visibility: Visibility, } /// Update a repo's visibility. The repo must be owned by the authenticated user. #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")] pub(super) async fn update_repo_visibility( State(db): State, AuthUser(user): AuthUser, Path(repo_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; // Indexed lookup by primary key, not fetch-all-then-find over the user's // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below. let repo = db::git_repos::get_repo_by_id(&db, repo_id) .await? .ok_or(AppError::NotFound)?; if repo.user_id != user.id { return Err(AppError::Forbidden); } db::git_repos::update_visibility(&db, repo_id, req.visibility).await?; Ok(htmx_toast_response("Visibility updated", "success")) } // Project Members API /// Form input for adding a project member. #[derive(Debug, Deserialize)] pub(super) struct AddMemberForm { pub username: String, pub split_percent: i16, pub role: Option, } /// POST /api/projects/{id}/members - Add a member to a project #[tracing::instrument(skip_all, name = "api::add_project_member")] pub(super) async fn add_project_member( State(db): State, AuthUser(session_user): AuthUser, Path(project_id): Path, Form(form): Form, ) -> Result { let _project = verify_project_ownership(&db, project_id, session_user.id).await?; // Validate split percent if form.split_percent < 1 || form.split_percent > 99 { return Err(AppError::validation( "Split must be between 1% and 99%".to_string(), )); } // Look up the member by username (validate untrusted form input) let username = db::Username::new(&form.username)?; let member_user = db::users::get_user_by_username(&db, &username) .await? .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; // Can't add yourself if member_user.id == session_user.id { return Err(AppError::validation( "You are already the project owner".to_string(), )); } let role = form.role.unwrap_or(db::ProjectRole::Member); db::project_members::add_project_member( &db, project_id, member_user.id, role, form.split_percent, session_user.id, ) .await?; // Bump cache generation so the tab refreshes db::projects::bump_cache_generation(&db, project_id).await?; // Say it at the only moment there is: there is no acceptance step, so the // owner is the only person in a position to hear it before money moves. The // collaborator sees the same fact on their own payments tab afterwards. let owner_currency = db::users::get_user_by_id(&db, session_user.id) .await? .map(|u| u.settlement_currency) .unwrap_or_default(); let message = if member_user.settlement_currency == owner_currency { format!( "Invited @{} to a {}% split. Their share starts when they accept.", member_user.username, form.split_percent ) } else { format!( "Invited @{} to a {}% split. This project sells in {}, but @{} is paid in {}, \ so Stripe converts their share when it reaches them and the conversion comes \ out of it. They will see that before they accept.", member_user.username, form.split_percent, owner_currency, member_user.username, member_user.settlement_currency ) }; Ok(htmx_toast_response(&message, "success").into_response()) } /// POST /api/projects/{id}/members/accept - Accept a split invitation. /// /// The invited creator acts on themselves, so there is no ownership check to /// make: the `WHERE user_id = $1 AND accepted_at IS NULL` in the query is the /// authorization. Someone with no pending invitation gets a 400, not somebody /// else's membership. #[tracing::instrument(skip_all, name = "api::accept_split_invitation")] pub(super) async fn accept_split_invitation( State(db): State, AuthUser(session_user): AuthUser, Path(project_id): Path, ) -> Result { session_user.check_not_suspended()?; let accepted = db::project_members::accept_split_invitation(&db, project_id, session_user.id).await?; if !accepted { return Err(AppError::validation( "No pending invitation for this project".to_string(), )); } tracing::info!(%project_id, user_id = %session_user.id, "split invitation accepted"); Ok(htmx_toast_response( "Split accepted. Your share starts from now, not from earlier sales.", "success", )) } /// POST /api/projects/{id}/members/decline - Decline a split invitation. #[tracing::instrument(skip_all, name = "api::decline_split_invitation")] pub(super) async fn decline_split_invitation( State(db): State, AuthUser(session_user): AuthUser, Path(project_id): Path, ) -> Result { session_user.check_not_suspended()?; let declined = db::project_members::decline_split_invitation(&db, project_id, session_user.id).await?; if !declined { return Err(AppError::validation( "No pending invitation for this project".to_string(), )); } tracing::info!(%project_id, user_id = %session_user.id, "split invitation declined"); Ok(htmx_toast_response("Invitation declined.", "success")) } /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member #[tracing::instrument(skip_all, name = "api::remove_project_member")] pub(super) async fn remove_project_member( State(db): State, AuthUser(session_user): AuthUser, Path((project_id, user_id)): Path<(ProjectId, db::UserId)>, ) -> Result { verify_project_ownership(&db, project_id, session_user.id).await?; let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?; if !removed { return Err(AppError::NotFound); } db::projects::bump_cache_generation(&db, project_id).await?; Ok(htmx_toast_response("Member removed", "success").into_response()) } // Repo Collaborators API #[derive(Debug, Deserialize)] pub(super) struct AddCollaboratorForm { pub username: String, #[serde(default = "default_true")] pub can_push: bool, } fn default_true() -> bool { true } #[derive(Debug, Serialize)] pub(super) struct CollaboratorResponse { pub user_id: UserId, pub username: String, pub can_push: bool, pub created_at: String, } /// Verify the authenticated user owns this repo and return it. async fn verify_repo_ownership( db: &PgPool, repo_id: GitRepoId, user_id: UserId, ) -> Result { let repo = db::git_repos::get_repo_by_id(db, repo_id) .await? .ok_or(AppError::NotFound)?; if repo.user_id != user_id { return Err(AppError::Forbidden); } Ok(repo) } /// POST /api/repos/{id}/collaborators: add a collaborator by username. #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")] pub(super) async fn add_repo_collaborator( State(db): State, AuthUser(user): AuthUser, Path(repo_id): Path, Form(form): Form, ) -> Result { user.check_not_suspended()?; let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; let username = db::Username::new(&form.username)?; let collab_user = db::users::get_user_by_username(&db, &username) .await? .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; if collab_user.id == user.id { return Err(AppError::validation( "You are already the repo owner".to_string(), )); } db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push) .await .map_err(|e| { if let AppError::Database(ref db_err) = e && db_err .to_string() .contains("repo_collaborators_repo_id_user_id_key") { return AppError::validation("This user is already a collaborator".to_string()); } e })?; Ok(htmx_toast_response( &format!("Added @{} as collaborator", collab_user.username), "success", ) .into_response()) } /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator. #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")] pub(super) async fn remove_repo_collaborator( State(db): State, AuthUser(user): AuthUser, Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>, ) -> Result { user.check_not_suspended()?; let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?; if !removed { return Err(AppError::NotFound); } Ok(htmx_toast_response("Collaborator removed", "success").into_response()) } /// GET /api/repos/{id}/collaborators: list collaborators (JSON). #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")] pub(super) async fn list_repo_collaborators( State(db): State, AuthUser(user): AuthUser, Path(repo_id): Path, ) -> Result { let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?; let data: Vec = collabs .into_iter() .map(|c| CollaboratorResponse { user_id: c.user_id, username: c.username, can_push: c.can_push, created_at: c.created_at.format("%b %d, %Y").to_string(), }) .collect(); Ok(Json(ListResponse { data })) }