Skip to main content

max / makenotwork

30.5 KB · 883 lines History Blame Raw
1 //! Project API: create, update, delete.
2
3 use axum::{
4 Form, Json,
5 extract::{Path, State},
6 http::header::HeaderMap,
7 response::{IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::config::Config;
12 use crate::{AppStorage, Integrations};
13 use sqlx::PgPool;
14
15 use crate::{
16 auth::AuthUser,
17 db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility},
18 error::{AppError, Result, ResultExt},
19 helpers::{htmx_toast_response, is_htmx_request},
20 types::ListResponse,
21 validation,
22 };
23
24 use super::verify_project_ownership;
25 use crate::extractors::{ValidatedForm, ValidatedJson};
26
27 // Project API
28
29 /// Form input for creating a new project.
30 #[derive(Debug, Deserialize)]
31 pub(super) struct CreateProjectRequest {
32 pub slug: Slug,
33 pub title: String,
34 pub description: Option<String>,
35 #[serde(default)]
36 pub features: Vec<String>,
37 pub category: Option<String>,
38 }
39
40 /// JSON response representing a project.
41 #[derive(Debug, Serialize)]
42 pub(super) struct ProjectResponse {
43 pub id: ProjectId,
44 pub slug: String,
45 pub title: String,
46 pub description: Option<String>,
47 pub project_type: ProjectType,
48 pub features: Vec<String>,
49 pub is_public: bool,
50 }
51
52 /// Create a new project for the authenticated creator.
53 #[tracing::instrument(skip_all, name = "projects::create_project")]
54 pub(super) async fn create_project(
55 State(db): State<PgPool>,
56 State(integrations): State<Integrations>,
57 headers: HeaderMap,
58 AuthUser(user): AuthUser,
59 ValidatedForm(req): ValidatedForm<CreateProjectRequest>,
60 ) -> Result<Response> {
61 user.check_not_suspended()?;
62
63 // Gate: only creators can create projects
64 if !user.can_create_projects {
65 return Err(AppError::Forbidden);
66 }
67
68 // Validate input (slug is validated by Slug's Deserialize impl)
69 validation::validate_project_title(&req.title)?;
70 if let Some(ref desc) = req.description {
71 validation::validate_project_description(desc)?;
72 }
73
74 // Resolve category if provided
75 let category_id = if let Some(ref cat_name) = req.category {
76 let trimmed = cat_name.trim();
77 if trimmed.is_empty() {
78 None
79 } else {
80 let cat = db::categories::get_or_create_category(&db, trimmed).await?;
81 Some(cat.id)
82 }
83 } else {
84 None
85 };
86
87 // Validate feature values
88 for f in &req.features {
89 f.parse::<db::ProjectFeature>()
90 .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?;
91 }
92
93 let project = db::projects::create_project(
94 &db,
95 user.id,
96 &req.slug,
97 &req.title,
98 req.description.as_deref(),
99 &req.features,
100 )
101 .await?;
102
103 // Set category if resolved
104 if let Some(cat_id) = category_id {
105 db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?;
106 }
107
108 // Create default mailing lists (non-blocking)
109 if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &req.title).await {
110 tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists");
111 }
112
113 db::users::bump_cache_generation(&db, user.id).await?;
114 db::projects::bump_cache_generation(&db, project.id).await?;
115
116 // Fire-and-forget: provision a paired MT community
117 if let Some(ref mt) = integrations.mt_client {
118 let mt = mt.clone();
119 let db = db.clone();
120 let project_id = project.id;
121 let slug = project.slug.to_string();
122 let title = project.title.clone();
123 let desc = project.description.clone();
124 let username = user.username.to_string();
125 let display_name = user.display_name.clone();
126 let user_id = user.id;
127 tokio::spawn(async move {
128 match mt
129 .create_community(&crate::mt_client::CreateCommunityRequest {
130 name: title,
131 slug,
132 description: desc,
133 owner_mnw_id: *user_id,
134 owner_username: username,
135 owner_display_name: display_name,
136 })
137 .await
138 {
139 Ok(resp) => {
140 if let Err(e) =
141 db::projects::set_mt_community_id(&db, project_id, resp.community_id).await
142 {
143 tracing::warn!(error = ?e, "failed to store MT community ID");
144 }
145 }
146 Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"),
147 }
148 });
149 }
150
151 if is_htmx_request(&headers) {
152 // Return HX-Redirect header to redirect to the project dashboard
153 let mut response = Response::new(axum::body::Body::empty());
154 response.headers_mut().insert(
155 "HX-Redirect",
156 format!("/dashboard/project/{}", project.slug)
157 .parse()
158 .expect("static redirect path is valid"),
159 );
160 return Ok(response);
161 }
162
163 Ok(Json(ProjectResponse {
164 id: project.id,
165 slug: project.slug.to_string(),
166 title: project.title,
167 description: project.description,
168 project_type: project.project_type,
169 features: project.features,
170 is_public: project.is_public,
171 })
172 .into_response())
173 }
174
175 /// List all projects for the authenticated user.
176 #[tracing::instrument(skip_all, name = "projects::list_projects")]
177 pub(super) async fn list_projects(
178 State(db): State<PgPool>,
179 AuthUser(user): AuthUser,
180 ) -> Result<impl IntoResponse> {
181 let projects = db::projects::get_projects_by_user(&db, user.id).await?;
182
183 let data: Vec<ProjectResponse> = projects
184 .into_iter()
185 .map(|p| ProjectResponse {
186 id: p.id,
187 slug: p.slug.to_string(),
188 title: p.title,
189 description: p.description,
190 project_type: p.project_type,
191 features: p.features,
192 is_public: p.is_public,
193 })
194 .collect();
195
196 Ok(Json(ListResponse { data }))
197 }
198
199 /// JSON input for updating an existing project.
200 #[derive(Debug, Deserialize)]
201 pub(super) struct UpdateProjectRequest {
202 pub title: Option<String>,
203 pub description: Option<String>,
204 pub features: Option<Vec<String>>,
205 pub is_public: Option<bool>,
206 pub category: Option<String>,
207 /// Pricing model as kebab string: "free" | "buy_once" | "pwyw" | "subscription".
208 pub pricing_model: Option<String>,
209 /// Buy-once price in dollars. Required when pricing_model="buy_once".
210 pub price_dollars: Option<f64>,
211 /// PWYW minimum in dollars. Optional when pricing_model="pwyw".
212 pub pwyw_min_dollars: Option<f64>,
213 }
214
215 /// Update an existing project owned by the authenticated user.
216 #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))]
217 pub(super) async fn update_project(
218 State(db): State<PgPool>,
219 AuthUser(user): AuthUser,
220 Path(id): Path<ProjectId>,
221 ValidatedJson(req): ValidatedJson<UpdateProjectRequest>,
222 ) -> Result<impl IntoResponse> {
223 tracing::Span::current().record("project_id", tracing::field::display(&id));
224 user.check_not_suspended()?;
225 verify_project_ownership(&db, id, user.id).await?;
226
227 // Validate input (same rules as create_project, but all fields are optional)
228 if let Some(ref title) = req.title {
229 validation::validate_project_title(title)?;
230 }
231 if let Some(ref desc) = req.description {
232 validation::validate_project_description(desc)?;
233 }
234
235 // Resolve category if provided
236 if let Some(ref cat_name) = req.category {
237 let trimmed = cat_name.trim();
238 if trimmed.is_empty() {
239 db::projects::set_project_category(&db, id, user.id, None).await?;
240 } else {
241 let cat = db::categories::get_or_create_category(&db, trimmed).await?;
242 db::projects::set_project_category(&db, id, user.id, Some(cat.id)).await?;
243 }
244 }
245
246 // Validate feature values if provided
247 if let Some(ref features) = req.features {
248 for f in features {
249 f.parse::<db::ProjectFeature>()
250 .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?;
251 }
252 }
253
254 let updated = db::projects::update_project(
255 &db,
256 id,
257 user.id,
258 req.title.as_deref(),
259 req.description.as_deref(),
260 req.features.as_deref(),
261 req.is_public,
262 )
263 .await?;
264
265 if let Some(ref model_str) = req.pricing_model {
266 let kind: db::PricingKind = model_str
267 .parse()
268 .map_err(|_| AppError::validation(format!("Invalid pricing_model: {model_str}")))?;
269
270 let price_cents = if kind == db::PricingKind::BuyOnce {
271 let dollars = req
272 .price_dollars
273 .ok_or_else(|| AppError::validation("price_dollars required for buy_once"))?;
274 // Reject NaN/Inf/negative/overflow before the cast, the raw
275 // `(dollars * 100.0).round() as i32` form silently turns NaN into 0
276 // and saturates large values to i32::MAX.
277 let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?;
278 // `buy_once` enforces the cap, non-negative, and Stripe's minimum
279 // charge for the creator's settlement currency, in one place shared
280 // with the wizard writer. update_project_pricing takes PriceCents,
281 // so a bare i32 can't reach the DB (Run 11 UX F1).
282 db::PriceCents::buy_once(cents, user.settlement_currency)?
283 } else {
284 db::PriceCents::ZERO
285 };
286
287 let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
288 let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
289 let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?;
290 Some(db::PriceCents::new(cents)?)
291 } else {
292 None
293 };
294
295 db::projects::update_project_pricing(&db, id, user.id, kind, price_cents, pwyw_min_cents)
296 .await?;
297 }
298
299 db::projects::bump_cache_generation(&db, id).await?;
300
301 Ok(Json(ProjectResponse {
302 id: updated.id,
303 slug: updated.slug.to_string(),
304 title: updated.title,
305 description: updated.description,
306 project_type: updated.project_type,
307 features: updated.features,
308 is_public: updated.is_public,
309 }))
310 }
311
312 /// Form input for choosing a project's creator theme (Tier 0).
313 #[derive(Debug, Deserialize)]
314 pub(super) struct UpdateProjectThemeRequest {
315 /// Built-in theme id. Empty or absent clears to the platform default.
316 pub theme_id: Option<String>,
317 }
318
319 /// Set a project's creator theme. Applies to the project's public page and its
320 /// items (which inherit it). Bumps the project cache generation so cached pages
321 /// re-render with the new palette.
322 #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))]
323 pub(super) async fn update_project_theme(
324 State(db): State<PgPool>,
325 AuthUser(user): AuthUser,
326 Path(id): Path<ProjectId>,
327 Form(req): Form<UpdateProjectThemeRequest>,
328 ) -> Result<impl IntoResponse> {
329 tracing::Span::current().record("project_id", tracing::field::display(&id));
330 user.check_not_suspended()?;
331 verify_project_ownership(&db, id, user.id).await?;
332
333 let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref())
334 .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?;
335 db::projects::set_project_theme(&db, id, user.id, theme_id.as_deref()).await?;
336 db::projects::bump_cache_generation(&db, id).await?;
337
338 Ok(htmx_toast_response("Theme saved", "success"))
339 }
340
341 /// Delete a project owned by the authenticated user.
342 ///
343 /// Before deleting, enqueues all S3 keys (item files, version files, project
344 /// cover image) for durable deletion and decrements the user's storage counter.
345 #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))]
346 pub(super) async fn delete_project(
347 State(db): State<PgPool>,
348 State(config): State<Config>,
349 State(storage): State<AppStorage>,
350 AuthUser(user): AuthUser,
351 Path(id): Path<ProjectId>,
352 ) -> Result<impl IntoResponse> {
353 tracing::Span::current().record("project_id", tracing::field::display(&id));
354 user.check_not_suspended()?;
355 let project = verify_project_ownership(&db, id, user.id).await?;
356
357 // Collect all S3 keys from items + versions + galleries before CASCADE
358 // delete destroys them. Gallery rows (item_images / project_images) cascade
359 // away too, so their keys must be swept here or they orphan with no durable
360 // record (Run #18 Storage B2).
361 let item_keys = db::items::get_project_item_s3_keys(&db, id).await?;
362 let version_keys = db::items::get_project_version_s3_keys(&db, id).await?;
363 let gallery_keys = db::gallery_images::s3_keys_for_project(&db, id).await?;
364
365 let mut all_keys: Vec<(String, String)> = Vec::new();
366 // Version downloads are gated media, always the private bucket.
367 all_keys.extend(
368 version_keys
369 .into_iter()
370 .map(|k| (k, crate::storage::S3Bucket::Main.as_str().to_string())),
371 );
372 // Item keys (audio/video are private; the item cover is public) and gallery
373 // images (public) are content-image-or-staging keys of unknown promote state.
374 // Enqueue each under BOTH buckets; the reaper no-ops the bucket the object
375 // isn't in (audio/video's public row and a promoted cover's main row are
376 // harmless no-ops). See `both_bucket_delete`.
377 for k in item_keys.into_iter().chain(gallery_keys) {
378 all_keys.extend(crate::storage::both_bucket_delete(&k));
379 }
380
381 // Include the project cover image if present (public bucket, or staging in main).
382 if let Some(ref url) = project.cover_image_url
383 && let Some(key) = crate::storage::extract_s3_key_from_url(
384 url,
385 &config.cdn_base_url,
386 storage
387 .s3
388 .as_deref()
389 .map(crate::storage::StorageBackend::bucket),
390 config.storage.as_ref().map(|c| c.endpoint.as_str()),
391 )
392 {
393 all_keys.extend(crate::storage::both_bucket_delete(&key));
394 }
395
396 // Enqueue for durable S3 deletion (survives crashes) BEFORE the CASCADE delete.
397 // Abort on failure rather than warn-and-proceed: enqueue is the sole durable
398 // deletion path for the whole project's item/version/gallery/cover keys, so
399 // deleting the rows anyway would orphan every object with no record (ultra-fuzz
400 // Run 12 Storage F3 sibling). The reverse case (enqueue succeeds, delete fails)
401 // is safe, the reaper's is_s3_key_live guard skips keys whose rows still exist.
402 db::pending_s3_deletions::enqueue_deletions(&db, &all_keys, "project_delete").await?;
403
404 // Decrement storage before deleting rows
405 let storage_bytes = db::items::get_project_storage_bytes(&db, id).await?;
406 if storage_bytes > 0
407 && let Err(e) = db::creator_tiers::decrement_storage_used(&db, user.id, storage_bytes).await
408 {
409 tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete");
410 }
411
412 db::projects::delete_project(&db, id, user.id).await?;
413 db::users::bump_cache_generation(&db, user.id).await?;
414 Ok(htmx_toast_response("Project deleted", "success"))
415 }
416
417 // Git Repo Linking
418
419 /// JSON input for linking a repo to a project.
420 #[derive(Debug, Deserialize)]
421 pub(super) struct LinkRepoRequest {
422 pub name: String,
423 }
424
425 /// Link a git repo to a project. The repo must exist and be owned by the same user.
426 #[tracing::instrument(skip_all, name = "projects::link_repo")]
427 pub(super) async fn link_repo(
428 State(db): State<PgPool>,
429 AuthUser(user): AuthUser,
430 Path(id): Path<ProjectId>,
431 Json(req): Json<LinkRepoRequest>,
432 ) -> Result<impl IntoResponse> {
433 user.check_not_suspended()?;
434 verify_project_ownership(&db, id, user.id).await?;
435
436 let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &req.name)
437 .await?
438 .ok_or(AppError::validation("Repository not found".to_string()))?;
439
440 db::git_repos::link_repo_to_project(&db, repo.id, id).await?;
441 db::projects::bump_cache_generation(&db, id).await?;
442
443 Ok(htmx_toast_response("Repository linked", "success"))
444 }
445
446 /// Unlink a git repo from a project. The repo must be owned by the same user.
447 #[tracing::instrument(skip_all, name = "projects::unlink_repo")]
448 pub(super) async fn unlink_repo(
449 State(db): State<PgPool>,
450 AuthUser(user): AuthUser,
451 Path((id, repo_name)): Path<(ProjectId, String)>,
452 ) -> Result<impl IntoResponse> {
453 user.check_not_suspended()?;
454 verify_project_ownership(&db, id, user.id).await?;
455
456 let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &repo_name)
457 .await?
458 .ok_or(AppError::validation("Repository not found".to_string()))?;
459
460 db::git_repos::unlink_repo_from_project(&db, repo.id).await?;
461 db::projects::bump_cache_generation(&db, id).await?;
462
463 Ok(htmx_toast_response("Repository unlinked", "success"))
464 }
465
466 // Git Repo Creation + Visibility
467
468 /// JSON input for creating a bare repo on disk.
469 #[derive(Debug, Deserialize)]
470 pub(super) struct CreateRepoRequest {
471 pub name: String,
472 pub visibility: Option<Visibility>,
473 }
474
475 /// JSON response representing a git repo.
476 #[derive(Debug, Serialize)]
477 pub(super) struct RepoResponse {
478 pub id: GitRepoId,
479 pub name: String,
480 pub visibility: Visibility,
481 }
482
483 /// Create a bare git repo on disk and register it in the DB.
484 #[tracing::instrument(skip_all, name = "projects::create_repo")]
485 pub(super) async fn create_repo(
486 State(db): State<PgPool>,
487 State(config): State<Config>,
488 AuthUser(user): AuthUser,
489 Json(req): Json<CreateRepoRequest>,
490 ) -> Result<impl IntoResponse> {
491 user.check_not_suspended()?;
492 user.check_not_sandbox()?;
493
494 // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules)
495 let name = req.name.trim();
496 if name.is_empty() || name.len() > 64 {
497 return Err(AppError::validation(
498 "Repository name must be 1-64 characters".to_string(),
499 ));
500 }
501 if name.starts_with('.') || name == ".." {
502 return Err(AppError::validation("Invalid repository name".to_string()));
503 }
504 if !name
505 .chars()
506 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
507 {
508 return Err(AppError::validation(
509 "Repository name may only contain letters, numbers, hyphens, underscores, and dots"
510 .to_string(),
511 ));
512 }
513
514 // Validate visibility (enum deserialization handles validation)
515 let visibility = req.visibility.unwrap_or(Visibility::Public);
516
517 // Need git_repos_path configured
518 let git_root = config.build.git_repos_path.as_deref().ok_or_else(|| {
519 AppError::validation("Git repositories are not configured on this server".to_string())
520 })?;
521
522 // Check repo doesn't already exist in DB
523 if db::git_repos::get_repo_by_user_and_name(&db, user.id, name)
524 .await?
525 .is_some()
526 {
527 return Err(AppError::validation(
528 "A repository with that name already exists".to_string(),
529 ));
530 }
531
532 // Create bare repo on disk: {git_root}/{username}/{name}.git
533 let username = user.username.to_string();
534 let owner_dir = std::path::Path::new(git_root).join(&username);
535 let repo_dir = owner_dir.join(format!("{name}.git"));
536
537 if repo_dir.exists() {
538 return Err(AppError::validation(
539 "A repository with that name already exists on disk".to_string(),
540 ));
541 }
542
543 std::fs::create_dir_all(&owner_dir).context("create git owner directory")?;
544
545 crate::git::init_bare_repo(&repo_dir).context("init bare git repo")?;
546
547 // Install post-receive hook if build triggers are configured
548 if let Some(token) = &config.build.trigger_token {
549 let hooks_dir = repo_dir.join("hooks");
550 let hook_path = hooks_dir.join("post-receive");
551 let hook_content = crate::build_runner::post_receive_hook(token, &username, name);
552 if let Err(e) = std::fs::write(&hook_path, &hook_content) {
553 tracing::warn!(error = ?e, "failed to install post-receive hook");
554 } else {
555 #[cfg(unix)]
556 {
557 use std::os::unix::fs::PermissionsExt;
558 let _ =
559 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755));
560 }
561 }
562 }
563
564 // Register in DB
565 let db_repo =
566 db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?;
567
568 Ok(Json(RepoResponse {
569 id: db_repo.id,
570 name: db_repo.name,
571 visibility: db_repo.visibility,
572 }))
573 }
574
575 /// JSON input for updating repo visibility.
576 #[derive(Debug, Deserialize)]
577 pub(super) struct UpdateRepoVisibilityRequest {
578 pub visibility: Visibility,
579 }
580
581 /// Update a repo's visibility. The repo must be owned by the authenticated user.
582 #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
583 pub(super) async fn update_repo_visibility(
584 State(db): State<PgPool>,
585 AuthUser(user): AuthUser,
586 Path(repo_id): Path<GitRepoId>,
587 Json(req): Json<UpdateRepoVisibilityRequest>,
588 ) -> Result<impl IntoResponse> {
589 user.check_not_suspended()?;
590
591 // Indexed lookup by primary key, not fetch-all-then-find over the user's
592 // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below.
593 let repo = db::git_repos::get_repo_by_id(&db, repo_id)
594 .await?
595 .ok_or(AppError::NotFound)?;
596
597 if repo.user_id != user.id {
598 return Err(AppError::Forbidden);
599 }
600
601 db::git_repos::update_visibility(&db, repo_id, req.visibility).await?;
602
603 Ok(htmx_toast_response("Visibility updated", "success"))
604 }
605
606 // Project Members API
607
608 /// Form input for adding a project member.
609 #[derive(Debug, Deserialize)]
610 pub(super) struct AddMemberForm {
611 pub username: String,
612 pub split_percent: i16,
613 pub role: Option<db::ProjectRole>,
614 }
615
616 /// POST /api/projects/{id}/members - Add a member to a project
617 #[tracing::instrument(skip_all, name = "api::add_project_member")]
618 pub(super) async fn add_project_member(
619 State(db): State<PgPool>,
620 AuthUser(session_user): AuthUser,
621 Path(project_id): Path<ProjectId>,
622 Form(form): Form<AddMemberForm>,
623 ) -> Result<Response> {
624 let _project = verify_project_ownership(&db, project_id, session_user.id).await?;
625
626 // Validate split percent
627 if form.split_percent < 1 || form.split_percent > 99 {
628 return Err(AppError::validation(
629 "Split must be between 1% and 99%".to_string(),
630 ));
631 }
632
633 // Look up the member by username (validate untrusted form input)
634 let username = db::Username::new(&form.username)?;
635 let member_user = db::users::get_user_by_username(&db, &username)
636 .await?
637 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
638
639 // Can't add yourself
640 if member_user.id == session_user.id {
641 return Err(AppError::validation(
642 "You are already the project owner".to_string(),
643 ));
644 }
645
646 let role = form.role.unwrap_or(db::ProjectRole::Member);
647
648 db::project_members::add_project_member(
649 &db,
650 project_id,
651 member_user.id,
652 role,
653 form.split_percent,
654 session_user.id,
655 )
656 .await?;
657
658 // Bump cache generation so the tab refreshes
659 db::projects::bump_cache_generation(&db, project_id).await?;
660
661 // Say it at the only moment there is: there is no acceptance step, so the
662 // owner is the only person in a position to hear it before money moves. The
663 // collaborator sees the same fact on their own payments tab afterwards.
664 let owner_currency = db::users::get_user_by_id(&db, session_user.id)
665 .await?
666 .map(|u| u.settlement_currency)
667 .unwrap_or_default();
668 let message = if member_user.settlement_currency == owner_currency {
669 format!(
670 "Invited @{} to a {}% split. Their share starts when they accept.",
671 member_user.username, form.split_percent
672 )
673 } else {
674 format!(
675 "Invited @{} to a {}% split. This project sells in {}, but @{} is paid in {}, \
676 so Stripe converts their share when it reaches them and the conversion comes \
677 out of it. They will see that before they accept.",
678 member_user.username,
679 form.split_percent,
680 owner_currency,
681 member_user.username,
682 member_user.settlement_currency
683 )
684 };
685
686 Ok(htmx_toast_response(&message, "success").into_response())
687 }
688
689 /// POST /api/projects/{id}/members/accept - Accept a split invitation.
690 ///
691 /// The invited creator acts on themselves, so there is no ownership check to
692 /// make: the `WHERE user_id = $1 AND accepted_at IS NULL` in the query is the
693 /// authorization. Someone with no pending invitation gets a 400, not somebody
694 /// else's membership.
695 #[tracing::instrument(skip_all, name = "api::accept_split_invitation")]
696 pub(super) async fn accept_split_invitation(
697 State(db): State<PgPool>,
698 AuthUser(session_user): AuthUser,
699 Path(project_id): Path<ProjectId>,
700 ) -> Result<impl IntoResponse> {
701 session_user.check_not_suspended()?;
702
703 let accepted =
704 db::project_members::accept_split_invitation(&db, project_id, session_user.id).await?;
705 if !accepted {
706 return Err(AppError::validation(
707 "No pending invitation for this project".to_string(),
708 ));
709 }
710
711 tracing::info!(%project_id, user_id = %session_user.id, "split invitation accepted");
712 Ok(htmx_toast_response(
713 "Split accepted. Your share starts from now, not from earlier sales.",
714 "success",
715 ))
716 }
717
718 /// POST /api/projects/{id}/members/decline - Decline a split invitation.
719 #[tracing::instrument(skip_all, name = "api::decline_split_invitation")]
720 pub(super) async fn decline_split_invitation(
721 State(db): State<PgPool>,
722 AuthUser(session_user): AuthUser,
723 Path(project_id): Path<ProjectId>,
724 ) -> Result<impl IntoResponse> {
725 session_user.check_not_suspended()?;
726
727 let declined =
728 db::project_members::decline_split_invitation(&db, project_id, session_user.id).await?;
729 if !declined {
730 return Err(AppError::validation(
731 "No pending invitation for this project".to_string(),
732 ));
733 }
734
735 tracing::info!(%project_id, user_id = %session_user.id, "split invitation declined");
736 Ok(htmx_toast_response("Invitation declined.", "success"))
737 }
738
739 /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
740 #[tracing::instrument(skip_all, name = "api::remove_project_member")]
741 pub(super) async fn remove_project_member(
742 State(db): State<PgPool>,
743 AuthUser(session_user): AuthUser,
744 Path((project_id, user_id)): Path<(ProjectId, db::UserId)>,
745 ) -> Result<Response> {
746 verify_project_ownership(&db, project_id, session_user.id).await?;
747
748 let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?;
749
750 if !removed {
751 return Err(AppError::NotFound);
752 }
753
754 db::projects::bump_cache_generation(&db, project_id).await?;
755
756 Ok(htmx_toast_response("Member removed", "success").into_response())
757 }
758
759 // Repo Collaborators API
760
761 #[derive(Debug, Deserialize)]
762 pub(super) struct AddCollaboratorForm {
763 pub username: String,
764 #[serde(default = "default_true")]
765 pub can_push: bool,
766 }
767
768 fn default_true() -> bool {
769 true
770 }
771
772 #[derive(Debug, Serialize)]
773 pub(super) struct CollaboratorResponse {
774 pub user_id: UserId,
775 pub username: String,
776 pub can_push: bool,
777 pub created_at: String,
778 }
779
780 /// Verify the authenticated user owns this repo and return it.
781 async fn verify_repo_ownership(
782 db: &PgPool,
783 repo_id: GitRepoId,
784 user_id: UserId,
785 ) -> Result<db::DbGitRepo> {
786 let repo = db::git_repos::get_repo_by_id(db, repo_id)
787 .await?
788 .ok_or(AppError::NotFound)?;
789
790 if repo.user_id != user_id {
791 return Err(AppError::Forbidden);
792 }
793
794 Ok(repo)
795 }
796
797 /// POST /api/repos/{id}/collaborators: add a collaborator by username.
798 #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")]
799 pub(super) async fn add_repo_collaborator(
800 State(db): State<PgPool>,
801 AuthUser(user): AuthUser,
802 Path(repo_id): Path<GitRepoId>,
803 Form(form): Form<AddCollaboratorForm>,
804 ) -> Result<Response> {
805 user.check_not_suspended()?;
806
807 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
808
809 let username = db::Username::new(&form.username)?;
810 let collab_user = db::users::get_user_by_username(&db, &username)
811 .await?
812 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
813
814 if collab_user.id == user.id {
815 return Err(AppError::validation(
816 "You are already the repo owner".to_string(),
817 ));
818 }
819
820 db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push)
821 .await
822 .map_err(|e| {
823 if let AppError::Database(ref db_err) = e
824 && db_err
825 .to_string()
826 .contains("repo_collaborators_repo_id_user_id_key")
827 {
828 return AppError::validation("This user is already a collaborator".to_string());
829 }
830 e
831 })?;
832
833 Ok(htmx_toast_response(
834 &format!("Added @{} as collaborator", collab_user.username),
835 "success",
836 )
837 .into_response())
838 }
839
840 /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator.
841 #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")]
842 pub(super) async fn remove_repo_collaborator(
843 State(db): State<PgPool>,
844 AuthUser(user): AuthUser,
845 Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>,
846 ) -> Result<Response> {
847 user.check_not_suspended()?;
848
849 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
850
851 let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?;
852
853 if !removed {
854 return Err(AppError::NotFound);
855 }
856
857 Ok(htmx_toast_response("Collaborator removed", "success").into_response())
858 }
859
860 /// GET /api/repos/{id}/collaborators: list collaborators (JSON).
861 #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")]
862 pub(super) async fn list_repo_collaborators(
863 State(db): State<PgPool>,
864 AuthUser(user): AuthUser,
865 Path(repo_id): Path<GitRepoId>,
866 ) -> Result<impl IntoResponse> {
867 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
868
869 let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?;
870
871 let data: Vec<CollaboratorResponse> = collabs
872 .into_iter()
873 .map(|c| CollaboratorResponse {
874 user_id: c.user_id,
875 username: c.username,
876 can_push: c.can_push,
877 created_at: c.created_at.format("%b %d, %Y").to_string(),
878 })
879 .collect();
880
881 Ok(Json(ListResponse { data }))
882 }
883