Phase 4 backend: teams data layer + membership management
- 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:
co-authored by
Claude Opus 4.8
parent
4ad3220917
commit
2b8e23dd3f
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user