Skip to main content

max / makenotwork

25.9 KB · 768 lines History Blame Raw
1 //! Project API: create, update, delete.
2
3 use axum::{
4 extract::{Path, State},
5 http::header::HeaderMap,
6 response::{IntoResponse, Response},
7 Form, Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility},
14 error::{AppError, Result, ResultExt},
15 helpers::{htmx_toast_response, is_htmx_request},
16 types::ListResponse,
17 validation,
18 AppState,
19 };
20
21 use super::verify_project_ownership;
22
23 // =============================================================================
24 // Project API
25 // =============================================================================
26
27 /// Form input for creating a new project.
28 #[derive(Debug, Deserialize)]
29 pub struct CreateProjectRequest {
30 pub slug: Slug,
31 pub title: String,
32 pub description: Option<String>,
33 #[serde(default)]
34 pub features: Vec<String>,
35 pub category: Option<String>,
36 }
37
38 /// JSON response representing a project.
39 #[derive(Debug, Serialize)]
40 pub struct ProjectResponse {
41 pub id: ProjectId,
42 pub slug: String,
43 pub title: String,
44 pub description: Option<String>,
45 pub project_type: ProjectType,
46 pub features: Vec<String>,
47 pub is_public: bool,
48 }
49
50 /// Create a new project for the authenticated creator.
51 #[tracing::instrument(skip_all, name = "projects::create_project")]
52 pub(super) async fn create_project(
53 State(state): State<AppState>,
54 headers: HeaderMap,
55 AuthUser(user): AuthUser,
56 Form(req): Form<CreateProjectRequest>,
57 ) -> Result<Response> {
58 user.check_not_suspended()?;
59
60 // Gate: only creators can create projects
61 if !user.can_create_projects {
62 return Err(AppError::Forbidden);
63 }
64
65 // Validate input (slug is validated by Slug's Deserialize impl)
66 validation::validate_project_title(&req.title)?;
67 if let Some(ref desc) = req.description {
68 validation::validate_project_description(desc)?;
69 }
70
71 // Resolve category if provided
72 let category_id = if let Some(ref cat_name) = req.category {
73 let trimmed = cat_name.trim();
74 if !trimmed.is_empty() {
75 let cat = db::categories::get_or_create_category(&state.db, trimmed).await?;
76 Some(cat.id)
77 } else {
78 None
79 }
80 } else {
81 None
82 };
83
84 // Validate feature values
85 for f in &req.features {
86 f.parse::<db::ProjectFeature>()
87 .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?;
88 }
89
90 let project = db::projects::create_project(
91 &state.db,
92 user.id,
93 &req.slug,
94 &req.title,
95 req.description.as_deref(),
96 &req.features,
97 )
98 .await?;
99
100 // Set category if resolved
101 if let Some(cat_id) = category_id {
102 db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?;
103 }
104
105 // Create default mailing lists (non-blocking)
106 if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &req.title).await {
107 tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists");
108 }
109
110 db::users::bump_cache_generation(&state.db, user.id).await?;
111 db::projects::bump_cache_generation(&state.db, project.id).await?;
112
113 // Fire-and-forget: provision a paired MT community
114 if let Some(ref mt) = state.mt_client {
115 let mt = mt.clone();
116 let db = state.db.clone();
117 let project_id = project.id;
118 let slug = project.slug.to_string();
119 let title = project.title.clone();
120 let desc = project.description.clone();
121 let username = user.username.to_string();
122 let display_name = user.display_name.clone();
123 let user_id = user.id;
124 tokio::spawn(async move {
125 match mt
126 .create_community(&crate::mt_client::CreateCommunityRequest {
127 name: title,
128 slug,
129 description: desc,
130 owner_mnw_id: *user_id,
131 owner_username: username,
132 owner_display_name: display_name,
133 })
134 .await
135 {
136 Ok(resp) => {
137 if let Err(e) =
138 db::projects::set_mt_community_id(&db, project_id, resp.community_id).await
139 {
140 tracing::warn!(error = ?e, "failed to store MT community ID");
141 }
142 }
143 Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"),
144 }
145 });
146 }
147
148 if is_htmx_request(&headers) {
149 // Return HX-Redirect header to redirect to the project dashboard
150 let mut response = Response::new(axum::body::Body::empty());
151 response.headers_mut().insert(
152 "HX-Redirect",
153 format!("/dashboard/project/{}", project.slug)
154 .parse()
155 .expect("static redirect path is valid"),
156 );
157 return Ok(response);
158 }
159
160 Ok(Json(ProjectResponse {
161 id: project.id,
162 slug: project.slug.to_string(),
163 title: project.title,
164 description: project.description,
165 project_type: project.project_type,
166 features: project.features,
167 is_public: project.is_public,
168 }).into_response())
169 }
170
171 /// List all projects for the authenticated user.
172 #[tracing::instrument(skip_all, name = "projects::list_projects")]
173 pub(super) async fn list_projects(
174 State(state): State<AppState>,
175 AuthUser(user): AuthUser,
176 ) -> Result<impl IntoResponse> {
177 let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
178
179 let data: Vec<ProjectResponse> = projects
180 .into_iter()
181 .map(|p| ProjectResponse {
182 id: p.id,
183 slug: p.slug.to_string(),
184 title: p.title,
185 description: p.description,
186 project_type: p.project_type,
187 features: p.features,
188 is_public: p.is_public,
189 })
190 .collect();
191
192 Ok(Json(ListResponse { data }))
193 }
194
195 /// JSON input for updating an existing project.
196 #[derive(Debug, Deserialize)]
197 pub struct UpdateProjectRequest {
198 pub title: Option<String>,
199 pub description: Option<String>,
200 pub features: Option<Vec<String>>,
201 pub is_public: Option<bool>,
202 pub category: Option<String>,
203 /// Pricing model as kebab string: "free" | "buy_once" | "pwyw" | "subscription".
204 pub pricing_model: Option<String>,
205 /// Buy-once price in dollars. Required when pricing_model="buy_once".
206 pub price_dollars: Option<f64>,
207 /// PWYW minimum in dollars. Optional when pricing_model="pwyw".
208 pub pwyw_min_dollars: Option<f64>,
209 }
210
211 /// Update an existing project owned by the authenticated user.
212 #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))]
213 pub(super) async fn update_project(
214 State(state): State<AppState>,
215 AuthUser(user): AuthUser,
216 Path(id): Path<ProjectId>,
217 Json(req): Json<UpdateProjectRequest>,
218 ) -> Result<impl IntoResponse> {
219 tracing::Span::current().record("project_id", tracing::field::display(&id));
220 user.check_not_suspended()?;
221 verify_project_ownership(&state, id, user.id).await?;
222
223 // Validate input (same rules as create_project, but all fields are optional)
224 if let Some(ref title) = req.title {
225 validation::validate_project_title(title)?;
226 }
227 if let Some(ref desc) = req.description {
228 validation::validate_project_description(desc)?;
229 }
230
231 // Resolve category if provided
232 if let Some(ref cat_name) = req.category {
233 let trimmed = cat_name.trim();
234 if trimmed.is_empty() {
235 db::projects::set_project_category(&state.db, id, user.id, None).await?;
236 } else {
237 let cat = db::categories::get_or_create_category(&state.db, trimmed).await?;
238 db::projects::set_project_category(&state.db, id, user.id, Some(cat.id)).await?;
239 }
240 }
241
242 // Validate feature values if provided
243 if let Some(ref features) = req.features {
244 for f in features {
245 f.parse::<db::ProjectFeature>()
246 .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?;
247 }
248 }
249
250 let updated = db::projects::update_project(
251 &state.db,
252 id,
253 user.id,
254 req.title.as_deref(),
255 req.description.as_deref(),
256 req.features.as_deref(),
257 req.is_public,
258 )
259 .await?;
260
261 if let Some(ref model_str) = req.pricing_model {
262 let kind: db::PricingKind = model_str
263 .parse()
264 .map_err(|_| AppError::validation(format!("Invalid pricing_model: {model_str}")))?;
265
266 let price_cents = if kind == db::PricingKind::BuyOnce {
267 let dollars = req.price_dollars.ok_or_else(|| {
268 AppError::validation("price_dollars required for buy_once")
269 })?;
270 // Reject NaN/Inf/negative/overflow before the cast — the raw
271 // `(dollars * 100.0).round() as i32` form silently turns NaN into 0
272 // and saturates large values to i32::MAX.
273 let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?;
274 if cents < 50 {
275 return Err(AppError::validation(
276 "price_dollars must be at least 0.50",
277 ));
278 }
279 cents
280 } else {
281 0
282 };
283
284 let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
285 let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
286 Some(crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?)
287 } else {
288 None
289 };
290
291 db::projects::update_project_pricing(
292 &state.db,
293 id,
294 user.id,
295 kind,
296 price_cents,
297 pwyw_min_cents,
298 )
299 .await?;
300 }
301
302 db::projects::bump_cache_generation(&state.db, id).await?;
303
304 Ok(Json(ProjectResponse {
305 id: updated.id,
306 slug: updated.slug.to_string(),
307 title: updated.title,
308 description: updated.description,
309 project_type: updated.project_type,
310 features: updated.features,
311 is_public: updated.is_public,
312 }))
313 }
314
315 /// Delete a project owned by the authenticated user.
316 ///
317 /// Before deleting, enqueues all S3 keys (item files, version files, project
318 /// cover image) for durable deletion and decrements the user's storage counter.
319 #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))]
320 pub(super) async fn delete_project(
321 State(state): State<AppState>,
322 AuthUser(user): AuthUser,
323 Path(id): Path<ProjectId>,
324 ) -> Result<impl IntoResponse> {
325 tracing::Span::current().record("project_id", tracing::field::display(&id));
326 user.check_not_suspended()?;
327 let project = verify_project_ownership(&state, id, user.id).await?;
328
329 // Collect all S3 keys from items + versions before CASCADE delete destroys them
330 let item_keys = db::items::get_project_item_s3_keys(&state.db, id).await?;
331 let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?;
332
333 // Include the project cover image if present
334 let mut all_keys: Vec<(String, String)> = item_keys
335 .into_iter()
336 .chain(version_keys)
337 .map(|k| (k, "main".to_string()))
338 .collect();
339
340 if let Some(ref url) = project.cover_image_url
341 && let Some(key) = crate::storage::extract_s3_key_from_url(
342 url,
343 state.config.cdn_base_url.as_deref(),
344 state.s3.as_deref().map(|s| s.bucket()),
345 state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
346 )
347 {
348 all_keys.push((key, "main".to_string()));
349 }
350
351 // Enqueue for durable S3 deletion (survives crashes)
352 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
353 &state.db,
354 &all_keys,
355 "project_delete",
356 )
357 .await
358 {
359 tracing::warn!(error = ?e, "failed to enqueue S3 deletions for project");
360 }
361
362 // Decrement storage before deleting rows
363 let storage_bytes = db::items::get_project_storage_bytes(&state.db, id).await?;
364 if storage_bytes > 0
365 && let Err(e) = db::creator_tiers::decrement_storage_used(&state.db, user.id, storage_bytes).await
366 {
367 tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete");
368 }
369
370 db::projects::delete_project(&state.db, id, user.id).await?;
371 db::users::bump_cache_generation(&state.db, user.id).await?;
372 Ok(htmx_toast_response("Project deleted", "success"))
373 }
374
375 // =============================================================================
376 // Git Repo Linking
377 // =============================================================================
378
379 /// JSON input for linking a repo to a project.
380 #[derive(Debug, Deserialize)]
381 pub struct LinkRepoRequest {
382 pub name: String,
383 }
384
385 /// Link a git repo to a project. The repo must exist and be owned by the same user.
386 #[tracing::instrument(skip_all, name = "projects::link_repo")]
387 pub(super) async fn link_repo(
388 State(state): State<AppState>,
389 AuthUser(user): AuthUser,
390 Path(id): Path<ProjectId>,
391 Json(req): Json<LinkRepoRequest>,
392 ) -> Result<impl IntoResponse> {
393 user.check_not_suspended()?;
394 verify_project_ownership(&state, id, user.id).await?;
395
396 let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &req.name)
397 .await?
398 .ok_or(AppError::validation("Repository not found".to_string()))?;
399
400 db::git_repos::link_repo_to_project(&state.db, repo.id, id).await?;
401 db::projects::bump_cache_generation(&state.db, id).await?;
402
403 Ok(htmx_toast_response("Repository linked", "success"))
404 }
405
406 /// Unlink a git repo from a project. The repo must be owned by the same user.
407 #[tracing::instrument(skip_all, name = "projects::unlink_repo")]
408 pub(super) async fn unlink_repo(
409 State(state): State<AppState>,
410 AuthUser(user): AuthUser,
411 Path((id, repo_name)): Path<(ProjectId, String)>,
412 ) -> Result<impl IntoResponse> {
413 user.check_not_suspended()?;
414 verify_project_ownership(&state, id, user.id).await?;
415
416 let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &repo_name)
417 .await?
418 .ok_or(AppError::validation("Repository not found".to_string()))?;
419
420 db::git_repos::unlink_repo_from_project(&state.db, repo.id).await?;
421 db::projects::bump_cache_generation(&state.db, id).await?;
422
423 Ok(htmx_toast_response("Repository unlinked", "success"))
424 }
425
426 // =============================================================================
427 // Git Repo Creation + Visibility
428 // =============================================================================
429
430 /// JSON input for creating a bare repo on disk.
431 #[derive(Debug, Deserialize)]
432 pub struct CreateRepoRequest {
433 pub name: String,
434 pub visibility: Option<Visibility>,
435 }
436
437 /// JSON response representing a git repo.
438 #[derive(Debug, Serialize)]
439 pub struct RepoResponse {
440 pub id: GitRepoId,
441 pub name: String,
442 pub visibility: Visibility,
443 }
444
445 /// Create a bare git repo on disk and register it in the DB.
446 #[tracing::instrument(skip_all, name = "projects::create_repo")]
447 pub(super) async fn create_repo(
448 State(state): State<AppState>,
449 AuthUser(user): AuthUser,
450 Json(req): Json<CreateRepoRequest>,
451 ) -> Result<impl IntoResponse> {
452 user.check_not_suspended()?;
453 user.check_not_sandbox()?;
454
455 // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules)
456 let name = req.name.trim();
457 if name.is_empty() || name.len() > 64 {
458 return Err(AppError::validation("Repository name must be 1-64 characters".to_string()));
459 }
460 if name.starts_with('.') || name == ".." {
461 return Err(AppError::validation("Invalid repository name".to_string()));
462 }
463 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
464 return Err(AppError::validation(
465 "Repository name may only contain letters, numbers, hyphens, underscores, and dots".to_string(),
466 ));
467 }
468
469 // Validate visibility (enum deserialization handles validation)
470 let visibility = req.visibility.unwrap_or(Visibility::Public);
471
472 // Need git_repos_path configured
473 let git_root = state
474 .config
475 .git_repos_path
476 .as_deref()
477 .ok_or_else(|| AppError::validation("Git repositories are not configured on this server".to_string()))?;
478
479 // Check repo doesn't already exist in DB
480 if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() {
481 return Err(AppError::validation("A repository with that name already exists".to_string()));
482 }
483
484 // Create bare repo on disk: {git_root}/{username}/{name}.git
485 let username = user.username.to_string();
486 let owner_dir = std::path::Path::new(git_root).join(&username);
487 let repo_dir = owner_dir.join(format!("{name}.git"));
488
489 if repo_dir.exists() {
490 return Err(AppError::validation("A repository with that name already exists on disk".to_string()));
491 }
492
493 std::fs::create_dir_all(&owner_dir)
494 .context("create git owner directory")?;
495
496 git2::Repository::init_bare(&repo_dir)
497 .context("init bare git repo")?;
498
499 // Install post-receive hook if build triggers are configured
500 if let Some(token) = &state.config.build_trigger_token {
501 let hooks_dir = repo_dir.join("hooks");
502 let hook_path = hooks_dir.join("post-receive");
503 let hook_content = crate::build_runner::post_receive_hook(token, &username, name);
504 if let Err(e) = std::fs::write(&hook_path, &hook_content) {
505 tracing::warn!(error = ?e, "failed to install post-receive hook");
506 } else {
507 #[cfg(unix)]
508 {
509 use std::os::unix::fs::PermissionsExt;
510 let _ = std::fs::set_permissions(
511 &hook_path,
512 std::fs::Permissions::from_mode(0o755),
513 );
514 }
515 }
516 }
517
518 // Register in DB
519 let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?;
520
521 Ok(Json(RepoResponse {
522 id: db_repo.id,
523 name: db_repo.name,
524 visibility: db_repo.visibility,
525 }))
526 }
527
528 /// JSON input for updating repo visibility.
529 #[derive(Debug, Deserialize)]
530 pub struct UpdateRepoVisibilityRequest {
531 pub visibility: Visibility,
532 }
533
534 /// Update a repo's visibility. The repo must be owned by the authenticated user.
535 #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
536 pub(super) async fn update_repo_visibility(
537 State(state): State<AppState>,
538 AuthUser(user): AuthUser,
539 Path(repo_id): Path<GitRepoId>,
540 Json(req): Json<UpdateRepoVisibilityRequest>,
541 ) -> Result<impl IntoResponse> {
542 user.check_not_suspended()?;
543
544 let repo = db::git_repos::get_repos_by_user(&state.db, user.id)
545 .await?
546 .into_iter()
547 .find(|r| r.id == repo_id)
548 .ok_or(AppError::NotFound)?;
549
550 if repo.user_id != user.id {
551 return Err(AppError::Forbidden);
552 }
553
554 db::git_repos::update_visibility(&state.db, repo_id, req.visibility).await?;
555
556 Ok(htmx_toast_response("Visibility updated", "success"))
557 }
558
559 // =============================================================================
560 // Project Members API
561 // =============================================================================
562
563 /// Form input for adding a project member.
564 #[derive(Debug, Deserialize)]
565 pub struct AddMemberForm {
566 pub username: String,
567 pub split_percent: i16,
568 pub role: Option<db::ProjectRole>,
569 }
570
571 /// POST /api/projects/{id}/members - Add a member to a project
572 #[tracing::instrument(skip_all, name = "api::add_project_member")]
573 pub async fn add_project_member(
574 State(state): State<AppState>,
575 AuthUser(session_user): AuthUser,
576 Path(project_id): Path<String>,
577 Form(form): Form<AddMemberForm>,
578 ) -> Result<Response> {
579 let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
580 .map(ProjectId::from)
581 .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
582
583 let _project = verify_project_ownership(&state, project_id, session_user.id).await?;
584
585 // Validate split percent
586 if form.split_percent < 1 || form.split_percent > 99 {
587 return Err(AppError::validation("Split must be between 1% and 99%".to_string()));
588 }
589
590 // Look up the member by username (validate untrusted form input)
591 let username = db::Username::new(&form.username)?;
592 let member_user = db::users::get_user_by_username(&state.db, &username)
593 .await?
594 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
595
596 // Can't add yourself
597 if member_user.id == session_user.id {
598 return Err(AppError::validation("You are already the project owner".to_string()));
599 }
600
601 let role = form.role.unwrap_or(db::ProjectRole::Member);
602
603 db::project_members::add_project_member(
604 &state.db,
605 project_id,
606 member_user.id,
607 role,
608 form.split_percent,
609 session_user.id,
610 ).await?;
611
612 // Bump cache generation so the tab refreshes
613 db::projects::bump_cache_generation(&state.db, project_id).await?;
614
615 Ok(htmx_toast_response(
616 &format!("Added @{} with {}% split", member_user.username, form.split_percent),
617 "success",
618 ).into_response())
619 }
620
621 /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
622 #[tracing::instrument(skip_all, name = "api::remove_project_member")]
623 pub async fn remove_project_member(
624 State(state): State<AppState>,
625 AuthUser(session_user): AuthUser,
626 Path((project_id, user_id)): Path<(String, String)>,
627 ) -> Result<Response> {
628 let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
629 .map(ProjectId::from)
630 .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
631
632 let user_id: db::UserId = user_id.parse::<uuid::Uuid>()
633 .map(db::UserId::from)
634 .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;
635
636 verify_project_ownership(&state, project_id, session_user.id).await?;
637
638 let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?;
639
640 if !removed {
641 return Err(AppError::NotFound);
642 }
643
644 db::projects::bump_cache_generation(&state.db, project_id).await?;
645
646 Ok(htmx_toast_response("Member removed", "success").into_response())
647 }
648
649 // =============================================================================
650 // Repo Collaborators API
651 // =============================================================================
652
653 #[derive(Debug, Deserialize)]
654 pub struct AddCollaboratorForm {
655 pub username: String,
656 #[serde(default = "default_true")]
657 pub can_push: bool,
658 }
659
660 fn default_true() -> bool { true }
661
662 #[derive(Debug, Serialize)]
663 pub struct CollaboratorResponse {
664 pub user_id: UserId,
665 pub username: String,
666 pub can_push: bool,
667 pub created_at: String,
668 }
669
670 /// Verify the authenticated user owns this repo and return it.
671 async fn verify_repo_ownership(
672 state: &AppState,
673 repo_id: GitRepoId,
674 user_id: UserId,
675 ) -> Result<db::DbGitRepo> {
676 let repo = db::git_repos::get_repo_by_id(&state.db, repo_id)
677 .await?
678 .ok_or(AppError::NotFound)?;
679
680 if repo.user_id != user_id {
681 return Err(AppError::Forbidden);
682 }
683
684 Ok(repo)
685 }
686
687 /// POST /api/repos/{id}/collaborators: add a collaborator by username.
688 #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")]
689 pub async fn add_repo_collaborator(
690 State(state): State<AppState>,
691 AuthUser(user): AuthUser,
692 Path(repo_id): Path<GitRepoId>,
693 Form(form): Form<AddCollaboratorForm>,
694 ) -> Result<Response> {
695 user.check_not_suspended()?;
696
697 let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
698
699 let username = db::Username::new(&form.username)?;
700 let collab_user = db::users::get_user_by_username(&state.db, &username)
701 .await?
702 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
703
704 if collab_user.id == user.id {
705 return Err(AppError::validation("You are already the repo owner".to_string()));
706 }
707
708 db::repo_collaborators::add_collaborator(&state.db, repo_id, collab_user.id, form.can_push)
709 .await
710 .map_err(|e| {
711 if let AppError::Database(ref db_err) = e
712 && db_err.to_string().contains("repo_collaborators_repo_id_user_id_key")
713 {
714 return AppError::validation("This user is already a collaborator".to_string());
715 }
716 e
717 })?;
718
719 Ok(htmx_toast_response(
720 &format!("Added @{} as collaborator", collab_user.username),
721 "success",
722 ).into_response())
723 }
724
725 /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator.
726 #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")]
727 pub async fn remove_repo_collaborator(
728 State(state): State<AppState>,
729 AuthUser(user): AuthUser,
730 Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>,
731 ) -> Result<Response> {
732 user.check_not_suspended()?;
733
734 let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
735
736 let removed = db::repo_collaborators::remove_collaborator(&state.db, repo_id, collab_user_id).await?;
737
738 if !removed {
739 return Err(AppError::NotFound);
740 }
741
742 Ok(htmx_toast_response("Collaborator removed", "success").into_response())
743 }
744
745 /// GET /api/repos/{id}/collaborators: list collaborators (JSON).
746 #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")]
747 pub async fn list_repo_collaborators(
748 State(state): State<AppState>,
749 AuthUser(user): AuthUser,
750 Path(repo_id): Path<GitRepoId>,
751 ) -> Result<impl IntoResponse> {
752 let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
753
754 let collabs = db::repo_collaborators::list_collaborators(&state.db, repo_id).await?;
755
756 let data: Vec<CollaboratorResponse> = collabs
757 .into_iter()
758 .map(|c| CollaboratorResponse {
759 user_id: c.user_id,
760 username: c.username,
761 can_push: c.can_push,
762 created_at: c.created_at.format("%b %d, %Y").to_string(),
763 })
764 .collect();
765
766 Ok(Json(ListResponse { data }))
767 }
768