Phase 4 backend: teams data layer + membership management
CI / backend (push) Failing after 1m6s
CI / policy (push) Successful in 4s
CI / profile (push) Successful in 12s
CI / mobile (push) Successful in 17s

- Migration 0003: teams + team_memberships (owner/admin/member, seat_limit)
- POST /v1/teams (Team-tier gated), GET /v1/teams/{id},
  GET|POST /v1/teams/{id}/members, DELETE …/members/{userId}
- Add-by-email (existing accounts), seat-cap enforcement (402), role-based
  authorization (admin to mutate; non-members get 404; owner unremovable)
- Invite notification email; Tier::can_own_team()

100 backend tests; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 15:13:03 -05:00
co-authored by Claude Opus 4.8
parent 4ad3220917
commit 2b8e23dd3f
13 changed files with 647 additions and 2 deletions
+10 -1
View File
@@ -84,7 +84,16 @@ bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
store, QR); full app type-checks. Component/E2E runs need a simulator. store, QR); full app type-checks. Component/E2E runs need a simulator.
- **B4** Apple Wallet + analytics (backend) — done. - **B4** Apple Wallet + analytics (backend) — done.
### Phase 3 (in progress) ### Phase 4 (in progress)
- **Teams data layer (backend)** — done. `teams` + `team_memberships`
(migration 0003) with owner/admin/member roles. `POST /v1/teams` (Team-tier
gated), `GET /v1/teams/{id}`, `GET|POST /v1/teams/{id}/members`,
`DELETE …/members/{userId}`. Add-by-email (existing accounts), seat-cap
enforcement (402), role-based authorization (admin to mutate; non-members get
404 so teams aren't enumerable); owner can't be removed.
### Phase 3
- **Tier-gate enforcement (backend)** — done. `Tier` capability model - **Tier-gate enforcement (backend)** — done. `Tier` capability model
(pro-layers, geo-analytics, custom-domain, retention). Pro-only layer types (pro-layers, geo-analytics, custom-domain, retention). Pro-only layer types
@@ -5,5 +5,6 @@ pub mod cards;
pub mod health; pub mod health;
pub mod profile; pub mod profile;
pub mod share; pub mod share;
pub mod teams;
pub mod wallet; pub mod wallet;
pub mod webhooks; pub mod webhooks;
@@ -0,0 +1,81 @@
//! Team management handlers (PRD §21 Phase 4). All require auth; role checks
//! happen in the service.
use axum::extract::{Path, State};
use axum::Json;
use cardclaws_db::models::team::{MemberRow, TeamRow};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::services::team_service;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct CreateTeamRequest {
pub name: String,
#[serde(default)]
pub seat_limit: Option<i32>,
}
#[derive(Deserialize)]
pub struct AddMemberRequest {
pub email: String,
#[serde(default = "default_role")]
pub role: String,
}
fn default_role() -> String {
"member".to_string()
}
pub async fn create_team(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateTeamRequest>,
) -> ApiResult<Json<TeamRow>> {
Ok(Json(
team_service::create_team(&state, user.user_id, &req.name, req.seat_limit).await?,
))
}
pub async fn get_team(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<TeamRow>> {
Ok(Json(
team_service::get_team(&state, id, user.user_id).await?,
))
}
pub async fn list_members(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<MemberRow>>> {
Ok(Json(
team_service::list_members(&state, id, user.user_id).await?,
))
}
pub async fn add_member(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<AddMemberRequest>,
) -> ApiResult<Json<Value>> {
team_service::add_member(&state, id, user.user_id, &req.email, &req.role).await?;
Ok(Json(json!({ "status": "added" })))
}
pub async fn remove_member(
State(state): State<AppState>,
user: AuthUser,
Path((id, target_id)): Path<(Uuid, Uuid)>,
) -> ApiResult<Json<Value>> {
team_service::remove_member(&state, id, user.user_id, target_id).await?;
Ok(Json(json!({ "status": "removed" })))
}
@@ -4,7 +4,9 @@
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::handlers::{analytics, assets, auth, cards, health, profile, share, wallet, webhooks}; use crate::handlers::{
analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks,
};
use crate::middleware::cors; use crate::middleware::cors;
use crate::state::AppState; use crate::state::AppState;
@@ -42,6 +44,16 @@ pub fn build_router(state: AppState) -> Router {
.route("/s/:token", get(share::resolve_share)) .route("/s/:token", get(share::resolve_share))
.route("/profile/:handle/contact", post(profile::submit_contact)) .route("/profile/:handle/contact", post(profile::submit_contact))
.route("/webhooks/revenuecat", post(webhooks::revenuecat)) .route("/webhooks/revenuecat", post(webhooks::revenuecat))
.route("/teams", post(teams::create_team))
.route("/teams/:id", get(teams::get_team))
.route(
"/teams/:id/members",
get(teams::list_members).post(teams::add_member),
)
.route(
"/teams/:id/members/:userId",
axum::routing::delete(teams::remove_member),
)
.route("/analytics/event", post(analytics::ingest_event)) .route("/analytics/event", post(analytics::ingest_event))
.route("/assets/upload", post(assets::presign_upload)) .route("/assets/upload", post(assets::presign_upload))
.route("/assets/*key", axum::routing::delete(assets::delete_asset)); .route("/assets/*key", axum::routing::delete(assets::delete_asset));
@@ -4,5 +4,6 @@ pub mod billing_service;
pub mod card_service; pub mod card_service;
pub mod profile_service; pub mod profile_service;
pub mod share_service; pub mod share_service;
pub mod team_service;
pub mod vcard; pub mod vcard;
pub mod wallet_service; pub mod wallet_service;
@@ -0,0 +1,174 @@
//! Team management (PRD §21 Phase 4): create teams, manage members with roles,
//! and enforce the seat cap. Only Team/Enterprise tiers may own a team.
use cardclaws_db::models::team::{MemberRow, TeamRow};
use cardclaws_db::queries::{teams, users};
use cardclaws_types::AppError;
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::state::AppState;
/// Create a team owned by `owner_id` (who becomes the first member, role owner).
pub async fn create_team(
state: &AppState,
owner_id: Uuid,
name: &str,
seat_limit: Option<i32>,
) -> Result<TeamRow, AppError> {
if name.trim().is_empty() {
return Err(AppError::Validation("team name is required".into()));
}
let owner = users::find_by_id(&state.db, owner_id)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
if !owner.tier.can_own_team() {
return Err(AppError::TierLimit(
"creating a team requires the Team plan".into(),
));
}
if let Some(limit) = seat_limit {
if limit < 1 {
return Err(AppError::Validation("seat_limit must be at least 1".into()));
}
}
let team = teams::insert_team(&state.db, name, owner_id, seat_limit)
.await
.map_db()?;
teams::insert_membership(&state.db, team.id, owner_id, "owner")
.await
.map_db()?;
Ok(team)
}
pub async fn get_team(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
) -> Result<TeamRow, AppError> {
require_member(state, team_id, actor_id).await?;
teams::find_team(&state.db, team_id)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("team".into()))
}
pub async fn list_members(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
) -> Result<Vec<MemberRow>, AppError> {
require_member(state, team_id, actor_id).await?;
teams::list_members(&state.db, team_id).await.map_db()
}
/// Add a member by email. Admin/owner only; enforces the seat cap. The invitee
/// must already have a CardClaws account (pending-invite signup is future work).
pub async fn add_member(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
email: &str,
role: &str,
) -> Result<(), AppError> {
require_admin(state, team_id, actor_id).await?;
if !matches!(role, "member" | "admin") {
return Err(AppError::Validation("role must be member or admin".into()));
}
let team = teams::find_team(&state.db, team_id)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("team".into()))?;
if let Some(limit) = team.seat_limit {
let count = teams::count_members(&state.db, team_id).await.map_db()?;
if count >= limit as i64 {
return Err(AppError::TierLimit(format!(
"team is at its {limit}-seat limit; upgrade to add more"
)));
}
}
let invitee = users::find_by_email(&state.db, email)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("no CardClaws account for that email".into()))?;
teams::insert_membership(&state.db, team_id, invitee.id, role)
.await
.map_err(map_membership_conflict)?;
let html = format!(
"<p>You've been added to the team <strong>{}</strong> on CardClaws.</p>",
team.name
);
let _ = state
.email
.send(
&invitee.email,
"You've been added to a CardClaws team",
&html,
)
.await;
Ok(())
}
pub async fn remove_member(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
target_id: Uuid,
) -> Result<(), AppError> {
require_admin(state, team_id, actor_id).await?;
// The owner can't be removed (transfer/delete the team instead).
if teams::member_role(&state.db, team_id, target_id)
.await
.map_db()?
.as_deref()
== Some("owner")
{
return Err(AppError::Forbidden);
}
let removed = teams::delete_membership(&state.db, team_id, target_id)
.await
.map_db()?;
if removed == 0 {
return Err(AppError::NotFound("member".into()));
}
Ok(())
}
// ---- Authorization helpers ------------------------------------------------
async fn require_member(
state: &AppState,
team_id: Uuid,
user_id: Uuid,
) -> Result<String, AppError> {
teams::member_role(&state.db, team_id, user_id)
.await
.map_db()?
// NotFound (not Forbidden) so team existence isn't leaked to non-members.
.ok_or_else(|| AppError::NotFound("team".into()))
}
async fn require_admin(state: &AppState, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
let role = require_member(state, team_id, user_id).await?;
if role == "owner" || role == "admin" {
Ok(())
} else {
Err(AppError::Forbidden)
}
}
fn map_membership_conflict(e: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &e {
if db_err.is_unique_violation() {
return AppError::Conflict("already a member of this team".into());
}
}
AppError::Internal(format!("db: {e}"))
}
@@ -0,0 +1,220 @@
//! Team data layer + membership management (PRD §21 Phase 4).
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::TestApp;
/// Register a user, force them to Team tier, and create a team. Returns
/// (token, userId, teamId).
async fn team_owner(app: &TestApp, seat_limit: Option<i32>) -> (String, String, String) {
let (token, user_id) = app.register_and_user().await;
app.set_tier(&user_id, "team").await;
let body = match seat_limit {
Some(n) => json!({ "name": "RedClaw", "seat_limit": n }),
None => json!({ "name": "RedClaw" }),
};
let (status, team) = app
.request("POST", "/v1/teams", Some(&token), Some(body))
.await;
assert_eq!(status, StatusCode::OK, "create team failed: {team}");
(token, user_id, team["id"].as_str().unwrap().to_string())
}
/// Register a plain user and return (token, email).
async fn member_account(app: &TestApp) -> (String, String) {
let (_token, user_id) = app.register_and_user().await;
let email = app_email(app, &user_id).await;
(user_id, email)
}
async fn app_email(app: &TestApp, user_id: &str) -> String {
let id = uuid::Uuid::parse_str(user_id).unwrap();
let (email,): (String,) = sqlx::query_as("SELECT email FROM users WHERE id = $1")
.bind(id)
.fetch_one(&app.db)
.await
.unwrap();
email
}
#[tokio::test]
async fn free_tier_cannot_create_team() {
let app = require_app!();
let token = app.register_and_token().await; // free
let (status, body) = app
.request(
"POST",
"/v1/teams",
Some(&token),
Some(json!({ "name": "Nope" })),
)
.await;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert_eq!(body["code"], "tier_limit");
}
#[tokio::test]
async fn create_team_makes_owner_a_member() {
let app = require_app!();
let (token, owner_id, team_id) = team_owner(&app, None).await;
let (status, members) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let arr = members.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["userId"], owner_id);
assert_eq!(arr[0]["role"], "owner");
}
#[tokio::test]
async fn add_and_remove_member() {
let app = require_app!();
let (token, _, team_id) = team_owner(&app, None).await;
let (member_id, member_email) = member_account(&app).await;
let (add_status, _) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
Some(json!({ "email": member_email, "role": "admin" })),
)
.await;
assert_eq!(add_status, StatusCode::OK);
let (_, members) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
None,
)
.await;
assert_eq!(members.as_array().unwrap().len(), 2);
// The invitee got a notification email.
assert!(app
.email
.sent
.lock()
.unwrap()
.iter()
.any(|m| m.to == member_email));
let (rm_status, _) = app
.request(
"DELETE",
&format!("/v1/teams/{team_id}/members/{member_id}"),
Some(&token),
None,
)
.await;
assert_eq!(rm_status, StatusCode::OK);
let (_, after) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
None,
)
.await;
assert_eq!(after.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn add_member_unknown_email_is_404() {
let app = require_app!();
let (token, _, team_id) = team_owner(&app, None).await;
let (status, _) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
Some(json!({ "email": "[email protected]", "role": "member" })),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn seat_limit_is_enforced() {
let app = require_app!();
// seat_limit = 1; the owner already occupies it.
let (token, _, team_id) = team_owner(&app, Some(1)).await;
let (_, member_email) = member_account(&app).await;
let (status, body) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&token),
Some(json!({ "email": member_email, "role": "member" })),
)
.await;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert_eq!(body["code"], "tier_limit");
}
#[tokio::test]
async fn non_member_cannot_view_or_admin_team() {
let app = require_app!();
let (_, _, team_id) = team_owner(&app, None).await;
let intruder = app.register_and_token().await;
// Non-member: team existence is hidden (404).
let (get_status, _) = app
.request(
"GET",
&format!("/v1/teams/{team_id}"),
Some(&intruder),
None,
)
.await;
assert_eq!(get_status, StatusCode::NOT_FOUND);
let (add_status, _) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&intruder),
Some(json!({ "email": "[email protected]", "role": "member" })),
)
.await;
assert_eq!(add_status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn plain_member_cannot_add_members() {
let app = require_app!();
let (owner_token, _, team_id) = team_owner(&app, None).await;
// Create a plain-member account and add them with role member.
let (member_token, member_id) = app.register_and_user().await;
let member_email = app_email(&app, &member_id).await;
app.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&owner_token),
Some(json!({ "email": member_email, "role": "member" })),
)
.await;
// That member (role=member) may not add others → 403 Forbidden.
let (status, _) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/members"),
Some(&member_token),
Some(json!({ "email": "[email protected]", "role": "member" })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
@@ -0,0 +1,23 @@
-- 0003_teams.sql — Team tier data layer (PRD §21 Phase 4).
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- NULL = unlimited seats; otherwise the billed seat cap.
seat_limit INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_teams_owner ON teams(owner_id);
CREATE TABLE team_memberships (
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'member')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX idx_team_memberships_user ON team_memberships(user_id);
@@ -5,4 +5,5 @@ pub mod analytics;
pub mod card; pub mod card;
pub mod session; pub mod session;
pub mod share_link; pub mod share_link;
pub mod team;
pub mod user; pub mod user;
@@ -0,0 +1,25 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamRow {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub seat_limit: Option<i32>,
pub created_at: DateTime<Utc>,
}
/// A team member row joined with the user's identity, for member listings.
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MemberRow {
pub user_id: Uuid,
pub email: String,
pub display_name: String,
pub role: String,
pub created_at: DateTime<Utc>,
}
@@ -5,4 +5,5 @@ pub mod analytics;
pub mod cards; pub mod cards;
pub mod sessions; pub mod sessions;
pub mod share; pub mod share;
pub mod teams;
pub mod users; pub mod users;
@@ -0,0 +1,92 @@
//! Team + membership queries (PRD §21 Phase 4).
use uuid::Uuid;
use crate::models::team::{MemberRow, TeamRow};
use crate::Db;
pub async fn insert_team(
db: &Db,
name: &str,
owner_id: Uuid,
seat_limit: Option<i32>,
) -> Result<TeamRow, sqlx::Error> {
sqlx::query_as(
r#"INSERT INTO teams (name, owner_id, seat_limit)
VALUES ($1, $2, $3)
RETURNING id, name, owner_id, seat_limit, created_at"#,
)
.bind(name)
.bind(owner_id)
.bind(seat_limit)
.fetch_one(db)
.await
}
pub async fn find_team(db: &Db, id: Uuid) -> Result<Option<TeamRow>, sqlx::Error> {
sqlx::query_as(r#"SELECT id, name, owner_id, seat_limit, created_at FROM teams WHERE id = $1"#)
.bind(id)
.fetch_optional(db)
.await
}
pub async fn insert_membership(
db: &Db,
team_id: Uuid,
user_id: Uuid,
role: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"INSERT INTO team_memberships (team_id, user_id, role) VALUES ($1, $2, $3)"#)
.bind(team_id)
.bind(user_id)
.bind(role)
.execute(db)
.await?;
Ok(())
}
/// Return the caller's role in a team, if any.
pub async fn member_role(
db: &Db,
team_id: Uuid,
user_id: Uuid,
) -> Result<Option<String>, sqlx::Error> {
let row: Option<(String,)> =
sqlx::query_as(r#"SELECT role FROM team_memberships WHERE team_id = $1 AND user_id = $2"#)
.bind(team_id)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(row.map(|r| r.0))
}
pub async fn list_members(db: &Db, team_id: Uuid) -> Result<Vec<MemberRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT u.id AS user_id, u.email, u.display_name, m.role, m.created_at
FROM team_memberships m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY m.created_at ASC"#,
)
.bind(team_id)
.fetch_all(db)
.await
}
pub async fn count_members(db: &Db, team_id: Uuid) -> Result<i64, sqlx::Error> {
let (n,): (i64,) =
sqlx::query_as(r#"SELECT COUNT(*) FROM team_memberships WHERE team_id = $1"#)
.bind(team_id)
.fetch_one(db)
.await?;
Ok(n)
}
pub async fn delete_membership(db: &Db, team_id: Uuid, user_id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query(r#"DELETE FROM team_memberships WHERE team_id = $1 AND user_id = $2"#)
.bind(team_id)
.bind(user_id)
.execute(db)
.await?;
Ok(r.rows_affected())
}
@@ -42,6 +42,11 @@ impl Tier {
!matches!(self, Tier::Free) !matches!(self, Tier::Free)
} }
/// Only Team/Enterprise tiers can own a team (PRD §19.1).
pub fn can_own_team(self) -> bool {
matches!(self, Tier::Team | Tier::Enterprise)
}
/// Analytics retention window in days; `None` = unlimited (PRD §19.1). /// Analytics retention window in days; `None` = unlimited (PRD §19.1).
pub fn analytics_retention_days(self) -> Option<i64> { pub fn analytics_retention_days(self) -> Option<i64> {
match self { match self {