Skip to main content

max / makenotwork

30.1 KB · 871 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 // The name rules plus the `*.mnw` reservation, checked before anything is
495 // created so a refused name leaves no directory behind.
496 let name = req.name.trim();
497 crate::validation::validate_creatable_repo_name(name)?;
498
499 // Validate visibility (enum deserialization handles validation)
500 let visibility = req.visibility.unwrap_or(Visibility::Public);
501
502 // Need git_repos_path configured
503 let git_root = config.build.git_repos_path.as_deref().ok_or_else(|| {
504 AppError::validation("Git repositories are not configured on this server".to_string())
505 })?;
506
507 // Check repo doesn't already exist in DB
508 if db::git_repos::get_repo_by_user_and_name(&db, user.id, name)
509 .await?
510 .is_some()
511 {
512 return Err(AppError::validation(
513 "A repository with that name already exists".to_string(),
514 ));
515 }
516
517 // Create bare repo on disk: {git_root}/{username}/{name}.git
518 let username = user.username.to_string();
519 let owner_dir = std::path::Path::new(git_root).join(&username);
520 let repo_dir = owner_dir.join(format!("{name}.git"));
521
522 if repo_dir.exists() {
523 return Err(AppError::validation(
524 "A repository with that name already exists on disk".to_string(),
525 ));
526 }
527
528 std::fs::create_dir_all(&owner_dir).context("create git owner directory")?;
529
530 crate::git::init_bare_repo(&repo_dir).context("init bare git repo")?;
531
532 // Both hooks, not just `post-receive`: `update` is what enforces the
533 // reserved `refs/notes/mnw/*` prefix, and a repository with only the first
534 // enforces no policy while looking installed.
535 if let Err(e) = crate::git_ssh::install_hooks_for_repo(
536 &repo_dir,
537 config.build.trigger_token.as_deref(),
538 &username,
539 name,
540 ) {
541 tracing::warn!(error = ?e, "failed to install git hooks");
542 }
543
544 // Register in DB
545 let db_repo =
546 db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?;
547
548 Ok(Json(RepoResponse {
549 id: db_repo.id,
550 name: db_repo.name,
551 visibility: db_repo.visibility,
552 }))
553 }
554
555 /// JSON input for updating repo visibility.
556 #[derive(Debug, Deserialize)]
557 pub(super) struct UpdateRepoVisibilityRequest {
558 pub visibility: Visibility,
559 }
560
561 /// Update a repo's visibility. The repo must be owned by the authenticated user.
562 #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
563 pub(super) async fn update_repo_visibility(
564 State(db): State<PgPool>,
565 AuthUser(user): AuthUser,
566 Path(repo_id): Path<GitRepoId>,
567 Json(req): Json<UpdateRepoVisibilityRequest>,
568 ) -> Result<impl IntoResponse> {
569 user.check_not_suspended()?;
570
571 // Indexed lookup by primary key, not fetch-all-then-find over the user's
572 // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below.
573 let repo = db::git_repos::get_repo_by_id(&db, repo_id)
574 .await?
575 .ok_or(AppError::NotFound)?;
576
577 if repo.user_id != user.id {
578 return Err(AppError::Forbidden);
579 }
580
581 // Private permanently: publishing a set of annotations is a moderation and
582 // consent decision, not a toggle.
583 if db::git_repos::is_annotation_repo(&repo) && req.visibility != Visibility::Private {
584 return Err(AppError::validation(
585 "Your annotations repository is private permanently.".to_string(),
586 ));
587 }
588
589 db::git_repos::update_visibility(&db, repo_id, req.visibility).await?;
590
591 Ok(htmx_toast_response("Visibility updated", "success"))
592 }
593
594 // Project Members API
595
596 /// Form input for adding a project member.
597 #[derive(Debug, Deserialize)]
598 pub(super) struct AddMemberForm {
599 pub username: String,
600 pub split_percent: i16,
601 pub role: Option<db::ProjectRole>,
602 }
603
604 /// POST /api/projects/{id}/members - Add a member to a project
605 #[tracing::instrument(skip_all, name = "api::add_project_member")]
606 pub(super) async fn add_project_member(
607 State(db): State<PgPool>,
608 AuthUser(session_user): AuthUser,
609 Path(project_id): Path<ProjectId>,
610 Form(form): Form<AddMemberForm>,
611 ) -> Result<Response> {
612 let _project = verify_project_ownership(&db, project_id, session_user.id).await?;
613
614 // Validate split percent
615 if form.split_percent < 1 || form.split_percent > 99 {
616 return Err(AppError::validation(
617 "Split must be between 1% and 99%".to_string(),
618 ));
619 }
620
621 // Look up the member by username (validate untrusted form input)
622 let username = db::Username::new(&form.username)?;
623 let member_user = db::users::get_user_by_username(&db, &username)
624 .await?
625 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
626
627 // Can't add yourself
628 if member_user.id == session_user.id {
629 return Err(AppError::validation(
630 "You are already the project owner".to_string(),
631 ));
632 }
633
634 let role = form.role.unwrap_or(db::ProjectRole::Member);
635
636 db::project_members::add_project_member(
637 &db,
638 project_id,
639 member_user.id,
640 role,
641 form.split_percent,
642 session_user.id,
643 )
644 .await?;
645
646 // Bump cache generation so the tab refreshes
647 db::projects::bump_cache_generation(&db, project_id).await?;
648
649 // Say it at the only moment there is: there is no acceptance step, so the
650 // owner is the only person in a position to hear it before money moves. The
651 // collaborator sees the same fact on their own payments tab afterwards.
652 let owner_currency = db::users::get_user_by_id(&db, session_user.id)
653 .await?
654 .map(|u| u.settlement_currency)
655 .unwrap_or_default();
656 let message = if member_user.settlement_currency == owner_currency {
657 format!(
658 "Invited @{} to a {}% split. Their share starts when they accept.",
659 member_user.username, form.split_percent
660 )
661 } else {
662 format!(
663 "Invited @{} to a {}% split. This project sells in {}, but @{} is paid in {}, \
664 so Stripe converts their share when it reaches them and the conversion comes \
665 out of it. They will see that before they accept.",
666 member_user.username,
667 form.split_percent,
668 owner_currency,
669 member_user.username,
670 member_user.settlement_currency
671 )
672 };
673
674 Ok(htmx_toast_response(&message, "success").into_response())
675 }
676
677 /// POST /api/projects/{id}/members/accept - Accept a split invitation.
678 ///
679 /// The invited creator acts on themselves, so there is no ownership check to
680 /// make: the `WHERE user_id = $1 AND accepted_at IS NULL` in the query is the
681 /// authorization. Someone with no pending invitation gets a 400, not somebody
682 /// else's membership.
683 #[tracing::instrument(skip_all, name = "api::accept_split_invitation")]
684 pub(super) async fn accept_split_invitation(
685 State(db): State<PgPool>,
686 AuthUser(session_user): AuthUser,
687 Path(project_id): Path<ProjectId>,
688 ) -> Result<impl IntoResponse> {
689 session_user.check_not_suspended()?;
690
691 let accepted =
692 db::project_members::accept_split_invitation(&db, project_id, session_user.id).await?;
693 if !accepted {
694 return Err(AppError::validation(
695 "No pending invitation for this project".to_string(),
696 ));
697 }
698
699 tracing::info!(%project_id, user_id = %session_user.id, "split invitation accepted");
700 Ok(htmx_toast_response(
701 "Split accepted. Your share starts from now, not from earlier sales.",
702 "success",
703 ))
704 }
705
706 /// POST /api/projects/{id}/members/decline - Decline a split invitation.
707 #[tracing::instrument(skip_all, name = "api::decline_split_invitation")]
708 pub(super) async fn decline_split_invitation(
709 State(db): State<PgPool>,
710 AuthUser(session_user): AuthUser,
711 Path(project_id): Path<ProjectId>,
712 ) -> Result<impl IntoResponse> {
713 session_user.check_not_suspended()?;
714
715 let declined =
716 db::project_members::decline_split_invitation(&db, project_id, session_user.id).await?;
717 if !declined {
718 return Err(AppError::validation(
719 "No pending invitation for this project".to_string(),
720 ));
721 }
722
723 tracing::info!(%project_id, user_id = %session_user.id, "split invitation declined");
724 Ok(htmx_toast_response("Invitation declined.", "success"))
725 }
726
727 /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
728 #[tracing::instrument(skip_all, name = "api::remove_project_member")]
729 pub(super) async fn remove_project_member(
730 State(db): State<PgPool>,
731 AuthUser(session_user): AuthUser,
732 Path((project_id, user_id)): Path<(ProjectId, db::UserId)>,
733 ) -> Result<Response> {
734 verify_project_ownership(&db, project_id, session_user.id).await?;
735
736 let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?;
737
738 if !removed {
739 return Err(AppError::NotFound);
740 }
741
742 db::projects::bump_cache_generation(&db, project_id).await?;
743
744 Ok(htmx_toast_response("Member removed", "success").into_response())
745 }
746
747 // Repo Collaborators API
748
749 #[derive(Debug, Deserialize)]
750 pub(super) struct AddCollaboratorForm {
751 pub username: String,
752 #[serde(default = "default_true")]
753 pub can_push: bool,
754 }
755
756 fn default_true() -> bool {
757 true
758 }
759
760 #[derive(Debug, Serialize)]
761 pub(super) struct CollaboratorResponse {
762 pub user_id: UserId,
763 pub username: String,
764 pub can_push: bool,
765 pub created_at: String,
766 }
767
768 /// Verify the authenticated user owns this repo and return it.
769 async fn verify_repo_ownership(
770 db: &PgPool,
771 repo_id: GitRepoId,
772 user_id: UserId,
773 ) -> Result<db::DbGitRepo> {
774 let repo = db::git_repos::get_repo_by_id(db, repo_id)
775 .await?
776 .ok_or(AppError::NotFound)?;
777
778 if repo.user_id != user_id {
779 return Err(AppError::Forbidden);
780 }
781
782 Ok(repo)
783 }
784
785 /// POST /api/repos/{id}/collaborators: add a collaborator by username.
786 #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")]
787 pub(super) async fn add_repo_collaborator(
788 State(db): State<PgPool>,
789 AuthUser(user): AuthUser,
790 Path(repo_id): Path<GitRepoId>,
791 Form(form): Form<AddCollaboratorForm>,
792 ) -> Result<Response> {
793 user.check_not_suspended()?;
794
795 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
796
797 let username = db::Username::new(&form.username)?;
798 let collab_user = db::users::get_user_by_username(&db, &username)
799 .await?
800 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
801
802 if collab_user.id == user.id {
803 return Err(AppError::validation(
804 "You are already the repo owner".to_string(),
805 ));
806 }
807
808 db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push)
809 .await
810 .map_err(|e| {
811 if let AppError::Database(ref db_err) = e
812 && db_err
813 .to_string()
814 .contains("repo_collaborators_repo_id_user_id_key")
815 {
816 return AppError::validation("This user is already a collaborator".to_string());
817 }
818 e
819 })?;
820
821 Ok(htmx_toast_response(
822 &format!("Added @{} as collaborator", collab_user.username),
823 "success",
824 )
825 .into_response())
826 }
827
828 /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator.
829 #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")]
830 pub(super) async fn remove_repo_collaborator(
831 State(db): State<PgPool>,
832 AuthUser(user): AuthUser,
833 Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>,
834 ) -> Result<Response> {
835 user.check_not_suspended()?;
836
837 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
838
839 let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?;
840
841 if !removed {
842 return Err(AppError::NotFound);
843 }
844
845 Ok(htmx_toast_response("Collaborator removed", "success").into_response())
846 }
847
848 /// GET /api/repos/{id}/collaborators: list collaborators (JSON).
849 #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")]
850 pub(super) async fn list_repo_collaborators(
851 State(db): State<PgPool>,
852 AuthUser(user): AuthUser,
853 Path(repo_id): Path<GitRepoId>,
854 ) -> Result<impl IntoResponse> {
855 let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
856
857 let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?;
858
859 let data: Vec<CollaboratorResponse> = collabs
860 .into_iter()
861 .map(|c| CollaboratorResponse {
862 user_id: c.user_id,
863 username: c.username,
864 can_push: c.can_push,
865 created_at: c.created_at.format("%b %d, %Y").to_string(),
866 })
867 .collect();
868
869 Ok(Json(ListResponse { data }))
870 }
871