Skip to main content

max / makenotwork

Add project members tab with revenue split management - Members tab on project dashboard with add/remove UI - Add member by username with split percentage and optional role - Owner's share auto-calculated as remainder (100% - member splits) - Stripe connection status shown per member - API endpoints: POST/DELETE /api/projects/{id}/members - ProjectMembersTabTemplate, ProjectMemberRow view type - Cache generation bumped on member changes for HTMX refresh Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-23 03:53 UTC
Commit: 1094cf7659ce6cce4338b1a73caad856f8aa1a3d
Parent: 0a038d8
9 files changed, +263 insertions, -1 deletion
@@ -209,6 +209,9 @@ pub fn api_routes() -> Router<AppState> {
209 209 .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility))
210 210 .route("/api/projects/{id}/repos", post(projects::link_repo))
211 211 .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo))
212 + // Project members
213 + .route("/api/projects/{id}/members", post(projects::add_project_member))
214 + .route("/api/projects/{project_id}/members/{user_id}", delete(projects::remove_project_member))
212 215 // Item routes
213 216 .route("/api/projects/{id}/items", post(items::create_item))
214 217 .route("/api/items/{id}", put(items::update_item))
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
10 10
11 11 use crate::{
12 12 auth::AuthUser,
13 - db::{self, GitRepoId, ProjectId, ProjectType, Slug},
13 + db::{self, GitRepoId, ProjectId, ProjectType, Slug, Username},
14 14 error::{AppError, Result},
15 15 helpers::{htmx_toast_response, is_htmx_request},
16 16 types::ListResponse,
@@ -467,3 +467,93 @@ pub(super) async fn update_repo_visibility(
467 467
468 468 Ok(htmx_toast_response("Visibility updated", "success"))
469 469 }
470 +
471 + // =============================================================================
472 + // Project Members API
473 + // =============================================================================
474 +
475 + /// Form input for adding a project member.
476 + #[derive(Debug, Deserialize)]
477 + pub struct AddMemberForm {
478 + pub username: String,
479 + pub split_percent: i16,
480 + pub role: Option<String>,
481 + }
482 +
483 + /// POST /api/projects/{id}/members - Add a member to a project
484 + #[tracing::instrument(skip_all, name = "api::add_project_member")]
485 + pub async fn add_project_member(
486 + State(state): State<AppState>,
487 + AuthUser(session_user): AuthUser,
488 + Path(project_id): Path<String>,
489 + Form(form): Form<AddMemberForm>,
490 + ) -> Result<Response> {
491 + let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
492 + .map(ProjectId::from)
493 + .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
494 +
495 + let project = verify_project_ownership(&state, project_id, session_user.id).await?;
496 +
497 + // Validate split percent
498 + if form.split_percent < 1 || form.split_percent > 99 {
499 + return Err(AppError::Validation("Split must be between 1% and 99%".to_string()));
500 + }
501 +
502 + // Look up the member by username
503 + let username = db::Username::from_trusted(form.username.clone());
504 + let member_user = db::users::get_user_by_username(&state.db, &username)
505 + .await?
506 + .ok_or_else(|| AppError::Validation(format!("User '{}' not found", form.username)))?;
507 +
508 + // Can't add yourself
509 + if member_user.id == session_user.id {
510 + return Err(AppError::Validation("You are already the project owner".to_string()));
511 + }
512 +
513 + let role = form.role.as_deref().unwrap_or("member");
514 +
515 + db::project_members::add_project_member(
516 + &state.db,
517 + project_id,
518 + member_user.id,
519 + role,
520 + form.split_percent,
521 + session_user.id,
522 + ).await?;
523 +
524 + // Bump cache generation so the tab refreshes
525 + db::projects::bump_cache_generation(&state.db, project_id).await?;
526 +
527 + Ok(htmx_toast_response(
528 + &format!("Added @{} with {}% split", member_user.username, form.split_percent),
529 + "success",
530 + ).into_response())
531 + }
532 +
533 + /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
534 + #[tracing::instrument(skip_all, name = "api::remove_project_member")]
535 + pub async fn remove_project_member(
536 + State(state): State<AppState>,
537 + AuthUser(session_user): AuthUser,
538 + Path((project_id, user_id)): Path<(String, String)>,
539 + ) -> Result<Response> {
540 + let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
541 + .map(ProjectId::from)
542 + .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
543 +
544 + let user_id: db::UserId = user_id.parse::<uuid::Uuid>()
545 + .map(db::UserId::from)
546 + .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;
547 +
548 + verify_project_ownership(&state, project_id, session_user.id).await?;
549 +
550 + let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?;
551 +
552 + if !removed {
553 + return Err(AppError::NotFound);
554 + }
555 +
556 + db::projects::bump_cache_generation(&state.db, project_id).await?;
557 +
558 + Ok(htmx_toast_response("Member removed", "success").into_response())
559 + }
@@ -63,6 +63,7 @@ pub fn dashboard_routes() -> Router<AppState> {
63 63 .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
64 64 .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
65 65 .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
66 + .route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))
66 67 .route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))
67 68 .route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))
68 69 .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))
@@ -402,3 +402,42 @@ pub(super) async fn project_tab_code(
402 402 project_id: db_project.id.to_string(),
403 403 }))
404 404 }
405 +
406 + /// Render the HTMX partial for the project members tab.
407 + #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]
408 + pub(super) async fn project_tab_members(
409 + State(state): State<AppState>,
410 + AuthUser(session_user): AuthUser,
411 + headers: HeaderMap,
412 + Path(slug): Path<String>,
413 + ) -> Result<axum::response::Response> {
414 + let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
415 + Ok(pair) => pair,
416 + Err(not_modified) => return Ok(not_modified),
417 + };
418 +
419 + let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?;
420 + let members: Vec<ProjectMemberRow> = db_members
421 + .iter()
422 + .map(|m| ProjectMemberRow {
423 + id: m.id.to_string(),
424 + user_id: m.user_id.to_string(),
425 + username: m.username.clone(),
426 + display_name: m.display_name.clone(),
427 + role: m.role.clone(),
428 + split_percent: m.split_percent,
429 + stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
430 + added_at: m.added_at.format("%Y-%m-%d").to_string(),
431 + })
432 + .collect();
433 +
434 + let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?;
435 + let owner_split = 100 - total_member_split;
436 +
437 + Ok(helpers::with_etag(generation, ProjectMembersTabTemplate {
438 + project_id: db_project.id.to_string(),
439 + project_slug: db_project.slug.to_string(),
440 + members,
441 + owner_split,
442 + }))
443 + }
@@ -124,6 +124,7 @@ impl_into_response!(
124 124 ProjectCodeTabTemplate,
125 125 ProjectBlogTabTemplate,
126 126 ProjectSubscriptionsTabTemplate,
127 + ProjectMembersTabTemplate,
127 128 ItemEditRowTemplate,
128 129 // Admin partials
129 130 AdminWaitlistEntriesTemplate,
@@ -324,6 +324,17 @@ pub struct ProjectSubscriptionsTabTemplate {
324 324 pub stripe_connected: bool,
325 325 }
326 326
327 + /// Dashboard members tab partial for managing project members and revenue splits.
328 + #[derive(Template)]
329 + #[template(path = "partials/tabs/project_members.html")]
330 + #[allow(dead_code)]
331 + pub struct ProjectMembersTabTemplate {
332 + pub project_id: String,
333 + pub project_slug: String,
334 + pub members: Vec<ProjectMemberRow>,
335 + pub owner_split: i64,
336 + }
337 +
327 338 /// SyncKit tab in the user dashboard for managing sync apps.
328 339 #[derive(Template)]
329 340 #[template(path = "partials/tabs/user_synckit.html")]
@@ -372,6 +372,19 @@ pub struct TipReceived {
372 372 pub message: Option<String>,
373 373 }
374 374
375 + /// Project member row for dashboard members tab
376 + #[derive(Clone)]
377 + pub struct ProjectMemberRow {
378 + pub id: String,
379 + pub user_id: String,
380 + pub username: String,
381 + pub display_name: Option<String>,
382 + pub role: String,
383 + pub split_percent: i16,
384 + pub stripe_connected: bool,
385 + pub added_at: String,
386 + }
387 +
375 388 /// Project card for dashboard
376 389 #[derive(Clone)]
377 390 pub struct ProjectCard {
@@ -99,6 +99,16 @@
99 99 hx-indicator="#tab-spinner"
100 100 onclick="setActiveTab(this)">Subscriptions</button>
101 101 {% endif %}
102 + <button class="tab"
103 + role="tab"
104 + aria-selected="false"
105 + aria-controls="tab-content"
106 + id="tab-members"
107 + hx-get="/dashboard/project/{{ project.slug }}/tabs/members"
108 + hx-target="#tab-content"
109 + hx-swap="innerHTML"
110 + hx-indicator="#tab-spinner"
111 + onclick="setActiveTab(this)">Members</button>
102 112 {% if git_enabled %}
103 113 <button class="tab"
104 114 role="tab"
@@ -0,0 +1,94 @@
1 + <div class="data-section">
2 + <h2>Members & Revenue Splits</h2>
3 + <p style="margin-bottom: 1.5rem; opacity: 0.7; text-align: left;">
4 + Add collaborators and assign revenue splits. The project owner receives the remainder after all member splits.
5 + </p>
6 +
7 + <div style="background: var(--surface-muted); padding: 1.25rem; margin-bottom: 1.5rem;">
8 + <div style="display: flex; justify-content: space-between; align-items: center;">
9 + <div>
10 + <div class="text-xs dimmed">Owner's share</div>
11 + <div style="font-size: 1.5rem; font-weight: bold;">{{ owner_split }}%</div>
12 + </div>
13 + <div>
14 + <div class="text-xs dimmed">Members</div>
15 + <div style="font-size: 1.5rem; font-weight: bold;">{{ members.len() }}</div>
16 + </div>
17 + </div>
18 + </div>
19 +
20 + <details class="form-section" style="margin-bottom: 2rem;">
21 + <summary><h2>Add Member</h2></summary>
22 + <form hx-post="/api/projects/{{ project_id }}/members"
23 + hx-swap="none"
24 + hx-on::after-request="if(event.detail.successful) { this.reset(); htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/members', '#tab-content'); }">
25 + <div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
26 + <div>
27 + <label for="member-username" style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Username</label>
28 + <input type="text" id="member-username" name="username" required
29 + placeholder="Enter username" style="width: 100%;">
30 + </div>
31 + <div>
32 + <label for="member-split" style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Split %</label>
33 + <input type="number" id="member-split" name="split_percent" required
34 + min="1" max="99" value="50" style="width: 80px;">
35 + </div>
36 + <div>
37 + <button class="primary" type="submit">Add</button>
38 + </div>
39 + </div>
40 + <div>
41 + <label for="member-role" style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem; margin-top: 0.75rem;">Role (optional)</label>
42 + <input type="text" id="member-role" name="role"
43 + placeholder="e.g. Producer, Artist, Engineer" style="width: 100%;">
44 + </div>
45 + <div id="member-add-result" style="margin-top: 0.5rem;"></div>
46 + </form>
47 + </details>
48 +
49 + {% if members.is_empty() %}
50 + <p style="opacity: 0.6; text-align: center; padding: 2rem 0;">No members added. The project owner receives 100% of revenue.</p>
51 + {% else %}
52 + <table class="data-table">
53 + <thead>
54 + <tr>
55 + <th>Member</th>
56 + <th>Role</th>
57 + <th>Split</th>
58 + <th>Stripe</th>
59 + <th>Added</th>
60 + <th></th>
61 + </tr>
62 + </thead>
63 + <tbody>
64 + {% for member in members %}
65 + <tr>
66 + <td>
67 + <a href="/u/{{ member.username }}" style="color: inherit;">
68 + <strong>{{ member.display_name.as_deref().unwrap_or(&member.username) }}</strong>
69 + </a>
70 + <div class="text-xs dimmed">@{{ member.username }}</div>
71 + </td>
72 + <td>{{ member.role }}</td>
73 + <td style="font-weight: bold;">{{ member.split_percent }}%</td>
74 + <td>
75 + {% if member.stripe_connected %}
76 + <span class="badge active">Connected</span>
77 + {% else %}
78 + <span class="badge pending">Not connected</span>
79 + {% endif %}
80 + </td>
81 + <td class="text-xs">{{ member.added_at }}</td>
82 + <td>
83 + <button class="danger" style="font-size: 0.8rem; padding: 0.25rem 0.5rem;"
84 + hx-delete="/api/projects/{{ project_id }}/members/{{ member.user_id }}"
85 + hx-swap="none"
86 + hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/members', '#tab-content');"
87 + hx-confirm="Remove {{ member.display_name.as_deref().unwrap_or(&member.username) }} from this project?">Remove</button>
88 + </td>
89 + </tr>
90 + {% endfor %}
91 + </tbody>
92 + </table>
93 + {% endif %}
94 + </div>