Skip to main content

max / makenotwork

30.5 KB · 882 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 $10k cap, non-negative, and the $0.50 floor
279 // in one place shared with the wizard writer. update_project_pricing
280 // takes PriceCents, so a bare i32 can't reach the DB (Run 11 UX F1).
281 db::PriceCents::buy_once(cents)?
282 } else {
283 db::PriceCents::ZERO
284 };
285
286 let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
287 let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
288 let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?;
289 Some(db::PriceCents::new(cents)?)
290 } else {
291 None
292 };
293
294 db::projects::update_project_pricing(&db, id, user.id, kind, price_cents, pwyw_min_cents)
295 .await?;
296 }
297
298 db::projects::bump_cache_generation(&db, id).await?;
299
300 Ok(Json(ProjectResponse {
301 id: updated.id,
302 slug: updated.slug.to_string(),
303 title: updated.title,
304 description: updated.description,
305 project_type: updated.project_type,
306 features: updated.features,
307 is_public: updated.is_public,
308 }))
309 }
310
311 /// Form input for choosing a project's creator theme (Tier 0).
312 #[derive(Debug, Deserialize)]
313 pub(super) struct UpdateProjectThemeRequest {
314 /// Built-in theme id. Empty or absent clears to the platform default.
315 pub theme_id: Option<String>,
316 }
317
318 /// Set a project's creator theme. Applies to the project's public page and its
319 /// items (which inherit it). Bumps the project cache generation so cached pages
320 /// re-render with the new palette.
321 #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))]
322 pub(super) async fn update_project_theme(
323 State(db): State<PgPool>,
324 AuthUser(user): AuthUser,
325 Path(id): Path<ProjectId>,
326 Form(req): Form<UpdateProjectThemeRequest>,
327 ) -> Result<impl IntoResponse> {
328 tracing::Span::current().record("project_id", tracing::field::display(&id));
329 user.check_not_suspended()?;
330 verify_project_ownership(&db, id, user.id).await?;
331
332 let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref())
333 .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?;
334 db::projects::set_project_theme(&db, id, user.id, theme_id.as_deref()).await?;
335 db::projects::bump_cache_generation(&db, id).await?;
336
337 Ok(htmx_toast_response("Theme saved", "success"))
338 }
339
340 /// Delete a project owned by the authenticated user.
341 ///
342 /// Before deleting, enqueues all S3 keys (item files, version files, project
343 /// cover image) for durable deletion and decrements the user's storage counter.
344 #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))]
345 pub(super) async fn delete_project(
346 State(db): State<PgPool>,
347 State(config): State<Config>,
348 State(storage): State<AppStorage>,
349 AuthUser(user): AuthUser,
350 Path(id): Path<ProjectId>,
351 ) -> Result<impl IntoResponse> {
352 tracing::Span::current().record("project_id", tracing::field::display(&id));
353 user.check_not_suspended()?;
354 let project = verify_project_ownership(&db, id, user.id).await?;
355
356 // Collect all S3 keys from items + versions + galleries before CASCADE
357 // delete destroys them. Gallery rows (item_images / project_images) cascade
358 // away too, so their keys must be swept here or they orphan with no durable
359 // record (Run #18 Storage B2).
360 let item_keys = db::items::get_project_item_s3_keys(&db, id).await?;
361 let version_keys = db::items::get_project_version_s3_keys(&db, id).await?;
362 let gallery_keys = db::gallery_images::s3_keys_for_project(&db, id).await?;
363
364 let mut all_keys: Vec<(String, String)> = Vec::new();
365 // Version downloads are gated media, always the private bucket.
366 all_keys.extend(
367 version_keys
368 .into_iter()
369 .map(|k| (k, crate::storage::S3Bucket::Main.as_str().to_string())),
370 );
371 // Item keys (audio/video are private; the item cover is public) and gallery
372 // images (public) are content-image-or-staging keys of unknown promote state.
373 // Enqueue each under BOTH buckets; the reaper no-ops the bucket the object
374 // isn't in (audio/video's public row and a promoted cover's main row are
375 // harmless no-ops). See `both_bucket_delete`.
376 for k in item_keys.into_iter().chain(gallery_keys) {
377 all_keys.extend(crate::storage::both_bucket_delete(&k));
378 }
379
380 // Include the project cover image if present (public bucket, or staging in main).
381 if let Some(ref url) = project.cover_image_url
382 && let Some(key) = crate::storage::extract_s3_key_from_url(
383 url,
384 &config.cdn_base_url,
385 storage
386 .s3
387 .as_deref()
388 .map(crate::storage::StorageBackend::bucket),
389 config.storage.as_ref().map(|c| c.endpoint.as_str()),
390 )
391 {
392 all_keys.extend(crate::storage::both_bucket_delete(&key));
393 }
394
395 // Enqueue for durable S3 deletion (survives crashes) BEFORE the CASCADE delete.
396 // Abort on failure rather than warn-and-proceed: enqueue is the sole durable
397 // deletion path for the whole project's item/version/gallery/cover keys, so
398 // deleting the rows anyway would orphan every object with no record (ultra-fuzz
399 // Run 12 Storage F3 sibling). The reverse case (enqueue succeeds, delete fails)
400 // is safe, the reaper's is_s3_key_live guard skips keys whose rows still exist.
401 db::pending_s3_deletions::enqueue_deletions(&db, &all_keys, "project_delete").await?;
402
403 // Decrement storage before deleting rows
404 let storage_bytes = db::items::get_project_storage_bytes(&db, id).await?;
405 if storage_bytes > 0
406 && let Err(e) = db::creator_tiers::decrement_storage_used(&db, user.id, storage_bytes).await
407 {
408 tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete");
409 }
410
411 db::projects::delete_project(&db, id, user.id).await?;
412 db::users::bump_cache_generation(&db, user.id).await?;
413 Ok(htmx_toast_response("Project deleted", "success"))
414 }
415
416 // Git Repo Linking
417
418 /// JSON input for linking a repo to a project.
419 #[derive(Debug, Deserialize)]
420 pub(super) struct LinkRepoRequest {
421 pub name: String,
422 }
423
424 /// Link a git repo to a project. The repo must exist and be owned by the same user.
425 #[tracing::instrument(skip_all, name = "projects::link_repo")]
426 pub(super) async fn link_repo(
427 State(db): State<PgPool>,
428 AuthUser(user): AuthUser,
429 Path(id): Path<ProjectId>,
430 Json(req): Json<LinkRepoRequest>,
431 ) -> Result<impl IntoResponse> {
432 user.check_not_suspended()?;
433 verify_project_ownership(&db, id, user.id).await?;
434
435 let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &req.name)
436 .await?
437 .ok_or(AppError::validation("Repository not found".to_string()))?;
438
439 db::git_repos::link_repo_to_project(&db, repo.id, id).await?;
440 db::projects::bump_cache_generation(&db, id).await?;
441
442 Ok(htmx_toast_response("Repository linked", "success"))
443 }
444
445 /// Unlink a git repo from a project. The repo must be owned by the same user.
446 #[tracing::instrument(skip_all, name = "projects::unlink_repo")]
447 pub(super) async fn unlink_repo(
448 State(db): State<PgPool>,
449 AuthUser(user): AuthUser,
450 Path((id, repo_name)): Path<(ProjectId, String)>,
451 ) -> Result<impl IntoResponse> {
452 user.check_not_suspended()?;
453 verify_project_ownership(&db, id, user.id).await?;
454
455 let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &repo_name)
456 .await?
457 .ok_or(AppError::validation("Repository not found".to_string()))?;
458
459 db::git_repos::unlink_repo_from_project(&db, repo.id).await?;
460 db::projects::bump_cache_generation(&db, id).await?;
461
462 Ok(htmx_toast_response("Repository unlinked", "success"))
463 }
464
465 // Git Repo Creation + Visibility
466
467 /// JSON input for creating a bare repo on disk.
468 #[derive(Debug, Deserialize)]
469 pub(super) struct CreateRepoRequest {
470 pub name: String,
471 pub visibility: Option<Visibility>,
472 }
473
474 /// JSON response representing a git repo.
475 #[derive(Debug, Serialize)]
476 pub(super) struct RepoResponse {
477 pub id: GitRepoId,
478 pub name: String,
479 pub visibility: Visibility,
480 }
481
482 /// Create a bare git repo on disk and register it in the DB.
483 #[tracing::instrument(skip_all, name = "projects::create_repo")]
484 pub(super) async fn create_repo(
485 State(db): State<PgPool>,
486 State(config): State<Config>,
487 AuthUser(user): AuthUser,
488 Json(req): Json<CreateRepoRequest>,
489 ) -> Result<impl IntoResponse> {
490 user.check_not_suspended()?;
491 user.check_not_sandbox()?;
492
493 // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules)
494 let name = req.name.trim();
495 if name.is_empty() || name.len() > 64 {
496 return Err(AppError::validation(
497 "Repository name must be 1-64 characters".to_string(),
498 ));
499 }
500 if name.starts_with('.') || name == ".." {
501 return Err(AppError::validation("Invalid repository name".to_string()));
502 }
503 if !name
504 .chars()
505 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
506 {
507 return Err(AppError::validation(
508 "Repository name may only contain letters, numbers, hyphens, underscores, and dots"
509 .to_string(),
510 ));
511 }
512
513 // Validate visibility (enum deserialization handles validation)
514 let visibility = req.visibility.unwrap_or(Visibility::Public);
515
516 // Need git_repos_path configured
517 let git_root = config.build.git_repos_path.as_deref().ok_or_else(|| {
518 AppError::validation("Git repositories are not configured on this server".to_string())
519 })?;
520
521 // Check repo doesn't already exist in DB
522 if db::git_repos::get_repo_by_user_and_name(&db, user.id, name)
523 .await?
524 .is_some()
525 {
526 return Err(AppError::validation(
527 "A repository with that name already exists".to_string(),
528 ));
529 }
530
531 // Create bare repo on disk: {git_root}/{username}/{name}.git
532 let username = user.username.to_string();
533 let owner_dir = std::path::Path::new(git_root).join(&username);
534 let repo_dir = owner_dir.join(format!("{name}.git"));
535
536 if repo_dir.exists() {
537 return Err(AppError::validation(
538 "A repository with that name already exists on disk".to_string(),
539 ));
540 }
541
542 std::fs::create_dir_all(&owner_dir).context("create git owner directory")?;
543
544 crate::git::init_bare_repo(&repo_dir).context("init bare git repo")?;
545
546 // Install post-receive hook if build triggers are configured
547 if let Some(token) = &config.build.trigger_token {
548 let hooks_dir = repo_dir.join("hooks");
549 let hook_path = hooks_dir.join("post-receive");
550 let hook_content = crate::build_runner::post_receive_hook(token, &username, name);
551 if let Err(e) = std::fs::write(&hook_path, &hook_content) {
552 tracing::warn!(error = ?e, "failed to install post-receive hook");
553 } else {
554 #[cfg(unix)]
555 {
556 use std::os::unix::fs::PermissionsExt;
557 let _ =
558 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755));
559 }
560 }
561 }
562
563 // Register in DB
564 let db_repo =
565 db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?;
566
567 Ok(Json(RepoResponse {
568 id: db_repo.id,
569 name: db_repo.name,
570 visibility: db_repo.visibility,
571 }))
572 }
573
574 /// JSON input for updating repo visibility.
575 #[derive(Debug, Deserialize)]
576 pub(super) struct UpdateRepoVisibilityRequest {
577 pub visibility: Visibility,
578 }
579
580 /// Update a repo's visibility. The repo must be owned by the authenticated user.
581 #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
582 pub(super) async fn update_repo_visibility(
583 State(db): State<PgPool>,
584 AuthUser(user): AuthUser,
585 Path(repo_id): Path<GitRepoId>,
586 Json(req): Json<UpdateRepoVisibilityRequest>,
587 ) -> Result<impl IntoResponse> {
588 user.check_not_suspended()?;
589
590 // Indexed lookup by primary key, not fetch-all-then-find over the user's
591 // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below.
592 let repo = db::git_repos::get_repo_by_id(&db, repo_id)
593 .await?
594 .ok_or(AppError::NotFound)?;
595
596 if repo.user_id != user.id {
597 return Err(AppError::Forbidden);
598 }
599
600 db::git_repos::update_visibility(&db, repo_id, req.visibility).await?;
601
602 Ok(htmx_toast_response("Visibility updated", "success"))
603 }
604
605 // Project Members API
606
607 /// Form input for adding a project member.
608 #[derive(Debug, Deserialize)]
609 pub(super) struct AddMemberForm {
610 pub username: String,
611 pub split_percent: i16,
612 pub role: Option<db::ProjectRole>,
613 }
614
615 /// POST /api/projects/{id}/members - Add a member to a project
616 #[tracing::instrument(skip_all, name = "api::add_project_member")]
617 pub(super) async fn add_project_member(
618 State(db): State<PgPool>,
619 AuthUser(session_user): AuthUser,
620 Path(project_id): Path<ProjectId>,
621 Form(form): Form<AddMemberForm>,
622 ) -> Result<Response> {
623 let _project = verify_project_ownership(&db, project_id, session_user.id).await?;
624
625 // Validate split percent
626 if form.split_percent < 1 || form.split_percent > 99 {
627 return Err(AppError::validation(
628 "Split must be between 1% and 99%".to_string(),
629 ));
630 }
631
632 // Look up the member by username (validate untrusted form input)
633 let username = db::Username::new(&form.username)?;
634 let member_user = db::users::get_user_by_username(&db, &username)
635 .await?
636 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
637
638 // Can't add yourself
639 if member_user.id == session_user.id {
640 return Err(AppError::validation(
641 "You are already the project owner".to_string(),
642 ));
643 }
644
645 let role = form.role.unwrap_or(db::ProjectRole::Member);
646
647 db::project_members::add_project_member(
648 &db,
649 project_id,
650 member_user.id,
651 role,
652 form.split_percent,
653 session_user.id,
654 )
655 .await?;
656
657 // Bump cache generation so the tab refreshes
658 db::projects::bump_cache_generation(&db, project_id).await?;
659
660 // Say it at the only moment there is: there is no acceptance step, so the
661 // owner is the only person in a position to hear it before money moves. The
662 // collaborator sees the same fact on their own payments tab afterwards.
663 let owner_currency = db::users::get_user_by_id(&db, session_user.id)
664 .await?
665 .map(|u| u.settlement_currency)
666 .unwrap_or_default();
667 let message = if member_user.settlement_currency == owner_currency {
668 format!(
669 "Invited @{} to a {}% split. Their share starts when they accept.",
670 member_user.username, form.split_percent
671 )
672 } else {
673 format!(
674 "Invited @{} to a {}% split. This project sells in {}, but @{} is paid in {}, \
675 so Stripe converts their share when it reaches them and the conversion comes \
676 out of it. They will see that before they accept.",
677 member_user.username,
678 form.split_percent,
679 owner_currency,
680 member_user.username,
681 member_user.settlement_currency
682 )
683 };
684
685 Ok(htmx_toast_response(&message, "success").into_response())
686 }
687
688 /// POST /api/projects/{id}/members/accept - Accept a split invitation.
689 ///
690 /// The invited creator acts on themselves, so there is no ownership check to
691 /// make: the `WHERE user_id = $1 AND accepted_at IS NULL` in the query is the
692 /// authorization. Someone with no pending invitation gets a 400, not somebody
693 /// else's membership.
694 #[tracing::instrument(skip_all, name = "api::accept_split_invitation")]
695 pub(super) async fn accept_split_invitation(
696 State(db): State<PgPool>,
697 AuthUser(session_user): AuthUser,
698 Path(project_id): Path<ProjectId>,
699 ) -> Result<impl IntoResponse> {
700 session_user.check_not_suspended()?;
701
702 let accepted =
703 db::project_members::accept_split_invitation(&db, project_id, session_user.id).await?;
704 if !accepted {
705 return Err(AppError::validation(
706 "No pending invitation for this project".to_string(),
707 ));
708 }
709
710 tracing::info!(%project_id, user_id = %session_user.id, "split invitation accepted");
711 Ok(htmx_toast_response(
712 "Split accepted. Your share starts from now, not from earlier sales.",
713 "success",
714 ))
715 }
716
717 /// POST /api/projects/{id}/members/decline - Decline a split invitation.
718 #[tracing::instrument(skip_all, name = "api::decline_split_invitation")]
719 pub(super) async fn decline_split_invitation(
720 State(db): State<PgPool>,
721 AuthUser(session_user): AuthUser,
722 Path(project_id): Path<ProjectId>,
723 ) -> Result<impl IntoResponse> {
724 session_user.check_not_suspended()?;
725
726 let declined =
727 db::project_members::decline_split_invitation(&db, project_id, session_user.id).await?;
728 if !declined {
729 return Err(AppError::validation(
730 "No pending invitation for this project".to_string(),
731 ));
732 }
733
734 tracing::info!(%project_id, user_id = %session_user.id, "split invitation declined");
735 Ok(htmx_toast_response("Invitation declined.", "success"))
736 }
737
738 /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
739 #[tracing::instrument(skip_all, name = "api::remove_project_member")]
740 pub(super) async fn remove_project_member(
741 State(db): State<PgPool>,
742 AuthUser(session_user): AuthUser,
743 Path((project_id, user_id)): Path<(ProjectId, db::UserId)>,
744 ) -> Result<Response> {
745 verify_project_ownership(&db, project_id, session_user.id).await?;
746
747 let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?;
748
749 if !removed {
750 return Err(AppError::NotFound);
751 }
752
753 db::projects::bump_cache_generation(&db, project_id).await?;
754
755 Ok(htmx_toast_response("Member removed", "success").into_response())
756 }
757
758 // Repo Collaborators API
759
760 #[derive(Debug, Deserialize)]
761 pub(super) struct AddCollaboratorForm {
762 pub username: String,
763 #[serde(default = "default_true")]
764 pub can_push: bool,
765 }
766
767 fn default_true() -> bool {
768 true
769 }
770
771 #[derive(Debug, Serialize)]
772 pub(super) struct CollaboratorResponse {
773 pub user_id: UserId,
774 pub username: String,
775 pub can_push: bool,
776 pub created_at: String,
777 }
778
779 /// Verify the authenticated user owns this repo and return it.
780 async fn verify_repo_ownership(
781 db: &PgPool,
782 repo_id: GitRepoId,
783 user_id: UserId,
784 ) -> Result<db::DbGitRepo> {
785 let repo = db::git_repos::get_repo_by_id(db, repo_id)
786 .await?
787 .ok_or(AppError::NotFound)?;
788
789 if repo.user_id != user_id {
790 return Err(AppError::Forbidden);
791 }
792
793 Ok(repo)
794 }
795
796 /// POST /api/repos/{id}/collaborators: add a collaborator by username.
797 #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")]
798 pub(super) async fn add_repo_collaborator(
799 State(db): State<PgPool>,
800 AuthUser(user): AuthUser,
801 Path(repo_id): Path<GitRepoId>,
802 Form(form): Form<AddCollaboratorForm>,
803 ) -> Result<Response> {
804 user.check_not_suspended()?;
805
806 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
807
808 let username = db::Username::new(&form.username)?;
809 let collab_user = db::users::get_user_by_username(&db, &username)
810 .await?
811 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
812
813 if collab_user.id == user.id {
814 return Err(AppError::validation(
815 "You are already the repo owner".to_string(),
816 ));
817 }
818
819 db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push)
820 .await
821 .map_err(|e| {
822 if let AppError::Database(ref db_err) = e
823 && db_err
824 .to_string()
825 .contains("repo_collaborators_repo_id_user_id_key")
826 {
827 return AppError::validation("This user is already a collaborator".to_string());
828 }
829 e
830 })?;
831
832 Ok(htmx_toast_response(
833 &format!("Added @{} as collaborator", collab_user.username),
834 "success",
835 )
836 .into_response())
837 }
838
839 /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator.
840 #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")]
841 pub(super) async fn remove_repo_collaborator(
842 State(db): State<PgPool>,
843 AuthUser(user): AuthUser,
844 Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>,
845 ) -> Result<Response> {
846 user.check_not_suspended()?;
847
848 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
849
850 let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?;
851
852 if !removed {
853 return Err(AppError::NotFound);
854 }
855
856 Ok(htmx_toast_response("Collaborator removed", "success").into_response())
857 }
858
859 /// GET /api/repos/{id}/collaborators: list collaborators (JSON).
860 #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")]
861 pub(super) async fn list_repo_collaborators(
862 State(db): State<PgPool>,
863 AuthUser(user): AuthUser,
864 Path(repo_id): Path<GitRepoId>,
865 ) -> Result<impl IntoResponse> {
866 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
867
868 let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?;
869
870 let data: Vec<CollaboratorResponse> = collabs
871 .into_iter()
872 .map(|c| CollaboratorResponse {
873 user_id: c.user_id,
874 username: c.username,
875 can_push: c.can_push,
876 created_at: c.created_at.format("%b %d, %Y").to_string(),
877 })
878 .collect();
879
880 Ok(Json(ListResponse { data }))
881 }
882