Skip to main content

max / makenotwork

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