Phase 4 + GDPR: team analytics, central templates, account export/deletion
CI / policy (push) Successful in 9s
CI / mobile (push) Successful in 21s
CI / profile (push) Successful in 20s
CI / backend (push) Failing after 1m3s

Teams:
- GET /v1/teams/{id}/analytics (admin) — per-card visits/qr/saves/clicks across
  members, sortable (whitelisted columns); …/analytics.csv exports RFC-4180 CSV
- Central template library (migration 0004 team_templates):
  POST|DELETE /v1/teams/{id}/templates[/{templateId}] (admin), GET (member)

GDPR (§18.3):
- GET /v1/account/export — full JSON dump via Postgres json_agg (no password hash)
- DELETE /v1/account — hard delete, FK cascade removes all owned data

107 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 16:15:39 -05:00
co-authored by Claude Opus 4.8
parent 2b8e23dd3f
commit 826ef69030
16 changed files with 801 additions and 5 deletions
+16
View File
@@ -84,6 +84,14 @@ 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.
### Cross-cutting
- **GDPR export + account deletion (backend)** — done. `GET /v1/account/export`
returns a full JSON dump (user, cards, share links, analytics, wallet
registrations, memberships — never the password hash, assembled via Postgres
`json_agg`). `DELETE /v1/account` hard-deletes the user; FK cascades remove
all owned data (PRD §18.3).
### Phase 4 (in progress) ### Phase 4 (in progress)
- **Teams data layer (backend)** — done. `teams` + `team_memberships` - **Teams data layer (backend)** — done. `teams` + `team_memberships`
@@ -92,6 +100,14 @@ bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
`DELETE …/members/{userId}`. Add-by-email (existing accounts), seat-cap `DELETE …/members/{userId}`. Add-by-email (existing accounts), seat-cap
enforcement (402), role-based authorization (admin to mutate; non-members get enforcement (402), role-based authorization (admin to mutate; non-members get
404 so teams aren't enumerable); owner can't be removed. 404 so teams aren't enumerable); owner can't be removed.
- **Team-aggregate analytics (backend)** — done. `GET /v1/teams/{id}/analytics`
(admin/owner only): per-card visits/qr/saves/clicks across all members,
sortable via `?sort=` (whitelisted columns). `…/analytics.csv` exports the
same as RFC-4180 CSV.
- **Central template management (backend)** — done. `team_templates`
(migration 0004); admins create/delete shared templates
(`POST|DELETE /v1/teams/{id}/templates[/{templateId}]`), members list them
(`GET`). Definition must be a JSON object.
### Phase 3 ### Phase 3
@@ -0,0 +1,24 @@
//! Account self-service handlers (PRD §18.3): data export + deletion.
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::services::account_service;
use crate::state::AppState;
pub async fn export_data(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
Ok(Json(
account_service::export_data(&state, user.user_id).await?,
))
}
pub async fn delete_account(
State(state): State<AppState>,
user: AuthUser,
) -> ApiResult<Json<Value>> {
account_service::delete_account(&state, user.user_id).await?;
Ok(Json(json!({ "status": "deleted" })))
}
@@ -1,3 +1,4 @@
pub mod account;
pub mod analytics; pub mod analytics;
pub mod assets; pub mod assets;
pub mod auth; pub mod auth;
@@ -1,9 +1,12 @@
//! Team management handlers (PRD §21 Phase 4). All require auth; role checks //! Team management handlers (PRD §21 Phase 4). All require auth; role checks
//! happen in the service. //! happen in the service.
use axum::extract::{Path, State}; use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use cardclaws_db::models::team::{MemberRow, TeamRow}; use cardclaws_db::models::team::{MemberRow, TeamCardStats, TeamRow, TeamTemplateRow};
use cardclaws_db::queries::teams::TeamStatsSort;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
@@ -13,6 +16,12 @@ use crate::middleware::auth::AuthUser;
use crate::services::team_service; use crate::services::team_service;
use crate::state::AppState; use crate::state::AppState;
#[derive(Deserialize)]
pub struct AnalyticsQuery {
#[serde(default)]
pub sort: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTeamRequest { pub struct CreateTeamRequest {
pub name: String, pub name: String,
@@ -79,3 +88,94 @@ pub async fn remove_member(
team_service::remove_member(&state, id, user.user_id, target_id).await?; team_service::remove_member(&state, id, user.user_id, target_id).await?;
Ok(Json(json!({ "status": "removed" }))) Ok(Json(json!({ "status": "removed" })))
} }
pub async fn analytics(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Query(q): Query<AnalyticsQuery>,
) -> ApiResult<Json<Vec<TeamCardStats>>> {
let sort = TeamStatsSort::parse(q.sort.as_deref());
Ok(Json(
team_service::aggregate_analytics(&state, id, user.user_id, sort).await?,
))
}
/// Same aggregate, exported as CSV (PRD §6.7.3).
pub async fn analytics_csv(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Query(q): Query<AnalyticsQuery>,
) -> ApiResult<Response> {
let sort = TeamStatsSort::parse(q.sort.as_deref());
let rows = team_service::aggregate_analytics(&state, id, user.user_id, sort).await?;
let mut csv = String::from("handle,owner,visits,qr_scans,contact_saves,link_clicks\n");
for r in &rows {
csv.push_str(&format!(
"{},{},{},{},{},{}\n",
csv_field(&r.handle),
csv_field(&r.owner_display_name),
r.visits,
r.qr_scans,
r.contact_saves,
r.link_clicks,
));
}
Ok((
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"team-analytics.csv\"",
),
],
csv,
)
.into_response())
}
#[derive(Deserialize)]
pub struct CreateTemplateRequest {
pub name: String,
pub definition: Value,
}
pub async fn create_template(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<CreateTemplateRequest>,
) -> ApiResult<Json<TeamTemplateRow>> {
Ok(Json(
team_service::create_template(&state, id, user.user_id, &req.name, &req.definition).await?,
))
}
pub async fn list_templates(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<TeamTemplateRow>>> {
Ok(Json(
team_service::list_templates(&state, id, user.user_id).await?,
))
}
pub async fn delete_template(
State(state): State<AppState>,
user: AuthUser,
Path((id, template_id)): Path<(Uuid, Uuid)>,
) -> ApiResult<Json<Value>> {
team_service::delete_template(&state, id, user.user_id, template_id).await?;
Ok(Json(json!({ "status": "deleted" })))
}
/// Quote a CSV field if it contains a comma, quote, or newline (RFC 4180).
fn csv_field(s: &str) -> String {
if s.contains([',', '"', '\n']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
@@ -5,7 +5,7 @@ use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::handlers::{ use crate::handlers::{
analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks, account, 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;
@@ -54,7 +54,19 @@ pub fn build_router(state: AppState) -> Router {
"/teams/:id/members/:userId", "/teams/:id/members/:userId",
axum::routing::delete(teams::remove_member), axum::routing::delete(teams::remove_member),
) )
.route("/teams/:id/analytics", get(teams::analytics))
.route("/teams/:id/analytics.csv", get(teams::analytics_csv))
.route(
"/teams/:id/templates",
get(teams::list_templates).post(teams::create_template),
)
.route(
"/teams/:id/templates/:templateId",
axum::routing::delete(teams::delete_template),
)
.route("/analytics/event", post(analytics::ingest_event)) .route("/analytics/event", post(analytics::ingest_event))
.route("/account/export", get(account::export_data))
.route("/account", axum::routing::delete(account::delete_account))
.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));
@@ -0,0 +1,35 @@
//! Account self-service: GDPR data export and account deletion (PRD §18.3).
use cardclaws_db::queries::{export, users};
use cardclaws_types::AppError;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::state::AppState;
/// Assemble a full export of everything tied to a user.
pub async fn export_data(state: &AppState, user_id: Uuid) -> Result<Value, AppError> {
let user = export::user_json(&state.db, user_id)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
Ok(json!({
"user": user,
"cards": export::cards_json(&state.db, user_id).await.map_db()?,
"shareLinks": export::share_links_json(&state.db, user_id).await.map_db()?,
"analyticsEvents": export::analytics_json(&state.db, user_id).await.map_db()?,
"walletRegistrations": export::wallet_json(&state.db, user_id).await.map_db()?,
"teamMemberships": export::memberships_json(&state.db, user_id).await.map_db()?,
}))
}
/// Permanently delete the account and everything it owns (FK cascade).
pub async fn delete_account(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
let removed = users::delete(&state.db, user_id).await.map_db()?;
if removed == 0 {
return Err(AppError::Unauthorized);
}
Ok(())
}
@@ -1,3 +1,4 @@
pub mod account_service;
pub mod analytics_service; pub mod analytics_service;
pub mod auth_service; pub mod auth_service;
pub mod billing_service; pub mod billing_service;
@@ -1,7 +1,8 @@
//! Team management (PRD §21 Phase 4): create teams, manage members with roles, //! 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. //! and enforce the seat cap. Only Team/Enterprise tiers may own a team.
use cardclaws_db::models::team::{MemberRow, TeamRow}; use cardclaws_db::models::team::{MemberRow, TeamCardStats, TeamRow, TeamTemplateRow};
use cardclaws_db::queries::teams::TeamStatsSort;
use cardclaws_db::queries::{teams, users}; use cardclaws_db::queries::{teams, users};
use cardclaws_types::AppError; use cardclaws_types::AppError;
use uuid::Uuid; use uuid::Uuid;
@@ -141,6 +142,67 @@ pub async fn remove_member(
Ok(()) Ok(())
} }
/// Per-card aggregate analytics across the team (admin/owner only, PRD §6.7.3).
pub async fn aggregate_analytics(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
sort: TeamStatsSort,
) -> Result<Vec<TeamCardStats>, AppError> {
require_admin(state, team_id, actor_id).await?;
teams::team_card_stats(&state.db, team_id, sort)
.await
.map_db()
}
// ---- Template library (PRD §6.1.4) ----------------------------------------
pub async fn create_template(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
name: &str,
definition: &serde_json::Value,
) -> Result<TeamTemplateRow, AppError> {
require_admin(state, team_id, actor_id).await?;
if name.trim().is_empty() {
return Err(AppError::Validation("template name is required".into()));
}
if !definition.is_object() {
return Err(AppError::Validation(
"definition must be a JSON object".into(),
));
}
teams::insert_template(&state.db, team_id, name, definition, actor_id)
.await
.map_db()
}
pub async fn list_templates(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
) -> Result<Vec<TeamTemplateRow>, AppError> {
require_member(state, team_id, actor_id).await?;
teams::list_templates(&state.db, team_id).await.map_db()
}
pub async fn delete_template(
state: &AppState,
team_id: Uuid,
actor_id: Uuid,
template_id: Uuid,
) -> Result<(), AppError> {
require_admin(state, team_id, actor_id).await?;
let removed = teams::delete_template(&state.db, team_id, template_id)
.await
.map_db()?;
if removed == 0 {
return Err(AppError::NotFound("template".into()));
}
Ok(())
}
// ---- Authorization helpers ------------------------------------------------ // ---- Authorization helpers ------------------------------------------------
async fn require_member( async fn require_member(
@@ -0,0 +1,117 @@
//! GDPR data export + account deletion (PRD §18.3).
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::{unique_handle, TestApp};
async fn create_card(app: &TestApp, token: &str) -> (String, String) {
let handle = unique_handle();
let def = json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "layers": [] }
});
let (_, created) = app
.request(
"POST",
"/v1/cards",
Some(token),
Some(json!({ "handle": handle, "definition": def })),
)
.await;
(created["id"].as_str().unwrap().to_string(), handle)
}
async fn count(app: &TestApp, sql: &str, id: &str) -> i64 {
let uuid = uuid::Uuid::parse_str(id).unwrap();
let (n,): (i64,) = sqlx::query_as(sql)
.bind(uuid)
.fetch_one(&app.db)
.await
.unwrap();
n
}
#[tokio::test]
async fn export_includes_user_and_cards() {
let app = require_app!();
let (token, user_id) = app.register_and_user().await;
let (_, handle) = create_card(&app, &token).await;
let (status, body) = app
.request("GET", "/v1/account/export", Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["user"]["id"], user_id);
// The password hash must never be exported.
assert!(body["user"].get("passwordHash").is_none());
assert!(body["user"].get("password_hash").is_none());
let cards = body["cards"].as_array().unwrap();
assert_eq!(cards.len(), 1);
assert_eq!(cards[0]["handle"], handle);
// Other sections are present as arrays.
for key in [
"shareLinks",
"analyticsEvents",
"walletRegistrations",
"teamMemberships",
] {
assert!(body[key].is_array(), "{key} should be an array");
}
}
#[tokio::test]
async fn export_requires_auth() {
let app = require_app!();
let (status, _) = app.request("GET", "/v1/account/export", None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn delete_account_cascades() {
let app = require_app!();
let (token, user_id) = app.register_and_user().await;
let (card_id, _) = create_card(&app, &token).await;
// Give the card a share link + event so cascade coverage is meaningful.
app.request(
"POST",
&format!("/v1/cards/{card_id}/share"),
Some(&token),
Some(json!({"modality": "qr"})),
)
.await;
let (status, _) = app
.request("DELETE", "/v1/account", Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK);
// User and all owned rows are gone.
assert_eq!(
count(&app, "SELECT COUNT(*) FROM users WHERE id = $1", &user_id).await,
0
);
assert_eq!(
count(
&app,
"SELECT COUNT(*) FROM cards WHERE owner_id = $1",
&user_id
)
.await,
0
);
assert_eq!(
count(
&app,
"SELECT COUNT(*) FROM share_links WHERE card_id = $1",
&card_id
)
.await,
0
);
}
@@ -192,6 +192,192 @@ async fn non_member_cannot_view_or_admin_team() {
assert_eq!(add_status, StatusCode::NOT_FOUND); assert_eq!(add_status, StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn team_aggregate_analytics_covers_members_cards() {
let app = require_app!();
let (token, _, team_id) = team_owner(&app, None).await;
// The owner publishes a card and gets a couple of profile visits.
let handle = common::unique_handle();
let def = json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "layers": [] }
});
let (_, card) = app
.request(
"POST",
"/v1/cards",
Some(&token),
Some(json!({ "handle": handle, "definition": def })),
)
.await;
let card_id = card["id"].as_str().unwrap();
app.request(
"POST",
&format!("/v1/cards/{card_id}/publish"),
Some(&token),
None,
)
.await;
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
// JSON aggregate (admin/owner only) includes that card with 2 visits.
let (status, rows) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/analytics?sort=visits"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let arr = rows.as_array().unwrap();
let row = arr
.iter()
.find(|r| r["handle"] == handle)
.expect("card present");
assert_eq!(row["visits"], 2);
// CSV export.
let (csv_status, content_type, body) = app
.request_raw(
"GET",
&format!("/v1/teams/{team_id}/analytics.csv"),
Some(&token),
)
.await;
assert_eq!(csv_status, StatusCode::OK);
assert!(content_type.starts_with("text/csv"));
assert!(body.starts_with("handle,owner,visits,qr_scans,contact_saves,link_clicks"));
assert!(body.contains(&handle));
}
#[tokio::test]
async fn team_analytics_requires_admin() {
let app = require_app!();
let (owner_token, _, team_id) = team_owner(&app, None).await;
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;
// A plain member cannot view team analytics.
let (status, _) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/analytics"),
Some(&member_token),
None,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_manages_templates_members_can_list() {
let app = require_app!();
let (owner_token, _, team_id) = team_owner(&app, None).await;
// Add a plain 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;
let template = json!({
"name": "Corporate",
"definition": {
"face": { "layers": [], "background": { "type": "solid", "value": "#1b1b2f" } },
"back": { "layers": [] }
}
});
// Admin/owner creates a template.
let (create_status, created) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/templates"),
Some(&owner_token),
Some(template.clone()),
)
.await;
assert_eq!(create_status, StatusCode::OK);
let template_id = created["id"].as_str().unwrap().to_string();
assert_eq!(created["name"], "Corporate");
// A plain member can list it.
let (list_status, list) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/templates"),
Some(&member_token),
None,
)
.await;
assert_eq!(list_status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 1);
// A plain member cannot create one.
let (forbidden, _) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/templates"),
Some(&member_token),
Some(template),
)
.await;
assert_eq!(forbidden, StatusCode::FORBIDDEN);
// Admin deletes it.
let (del_status, _) = app
.request(
"DELETE",
&format!("/v1/teams/{team_id}/templates/{template_id}"),
Some(&owner_token),
None,
)
.await;
assert_eq!(del_status, StatusCode::OK);
let (_, after) = app
.request(
"GET",
&format!("/v1/teams/{team_id}/templates"),
Some(&owner_token),
None,
)
.await;
assert_eq!(after.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn template_definition_must_be_object() {
let app = require_app!();
let (token, _, team_id) = team_owner(&app, None).await;
let (status, body) = app
.request(
"POST",
&format!("/v1/teams/{team_id}/templates"),
Some(&token),
Some(json!({ "name": "Bad", "definition": "not-an-object" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test] #[tokio::test]
async fn plain_member_cannot_add_members() { async fn plain_member_cannot_add_members() {
let app = require_app!(); let app = require_app!();
@@ -0,0 +1,13 @@
-- 0004_team_templates.sql — central template library for teams (PRD §6.1.4,
-- Phase 4: admin-managed templates shared with team members).
CREATE TABLE team_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
definition JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_team_templates_team ON team_templates(team_id);
@@ -13,6 +13,31 @@ pub struct TeamRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Aggregate analytics for one card belonging to a team member (PRD §6.7.3).
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamCardStats {
pub card_id: Uuid,
pub handle: String,
pub owner_display_name: String,
pub visits: i64,
pub qr_scans: i64,
pub contact_saves: i64,
pub link_clicks: i64,
}
/// A shared team template (PRD §6.1.4).
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamTemplateRow {
pub id: Uuid,
pub team_id: Uuid,
pub name: String,
pub definition: serde_json::Value,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
}
/// A team member row joined with the user's identity, for member listings. /// A team member row joined with the user's identity, for member listings.
#[derive(Debug, Clone, FromRow, Serialize)] #[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -0,0 +1,92 @@
//! GDPR data export (PRD §18.3). Each table is serialized to JSON in Postgres
//! via `json_agg`/`row_to_json`, so the export is assembled without a struct per
//! table. The user's `password_hash` is deliberately never selected.
use serde_json::Value;
use uuid::Uuid;
use crate::Db;
/// The user's own record (without the password hash). `None` if no such user.
pub async fn user_json(db: &Db, id: Uuid) -> Result<Option<Value>, sqlx::Error> {
let row: Option<(Value,)> = sqlx::query_as(
r#"SELECT row_to_json(t) FROM (
SELECT id, email, handle, display_name, tier, avatar_r2_key,
created_at, updated_at
FROM users WHERE id = $1
) t"#,
)
.bind(id)
.fetch_optional(db)
.await?;
Ok(row.map(|r| r.0))
}
async fn agg(db: &Db, sql: &str, owner: Uuid) -> Result<Value, sqlx::Error> {
let (v,): (Value,) = sqlx::query_as(sql).bind(owner).fetch_one(db).await?;
Ok(v)
}
pub async fn cards_json(db: &Db, owner: Uuid) -> Result<Value, sqlx::Error> {
agg(
db,
r#"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
SELECT id, handle, status, definition, version, created_at, updated_at
FROM cards WHERE owner_id = $1 ORDER BY created_at
) t"#,
owner,
)
.await
}
pub async fn share_links_json(db: &Db, owner: Uuid) -> Result<Value, sqlx::Error> {
agg(
db,
r#"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
SELECT s.token, s.card_id, s.modality, s.campaign, s.created_at, s.expires_at
FROM share_links s JOIN cards c ON c.id = s.card_id
WHERE c.owner_id = $1 ORDER BY s.created_at
) t"#,
owner,
)
.await
}
pub async fn analytics_json(db: &Db, owner: Uuid) -> Result<Value, sqlx::Error> {
agg(
db,
r#"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
SELECT e.id, e.card_id, e.event_type, e.country, e.city, e.occurred_at
FROM analytics_events e JOIN cards c ON c.id = e.card_id
WHERE c.owner_id = $1 ORDER BY e.occurred_at
) t"#,
owner,
)
.await
}
pub async fn wallet_json(db: &Db, owner: Uuid) -> Result<Value, sqlx::Error> {
agg(
db,
r#"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
SELECT w.id, w.card_id, w.platform, w.registered_at, w.last_updated_at
FROM wallet_registrations w JOIN cards c ON c.id = w.card_id
WHERE c.owner_id = $1
) t"#,
owner,
)
.await
}
pub async fn memberships_json(db: &Db, user_id: Uuid) -> Result<Value, sqlx::Error> {
agg(
db,
r#"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM (
SELECT m.team_id, t2.name AS team_name, m.role, m.created_at
FROM team_memberships m JOIN teams t2 ON t2.id = m.team_id
WHERE m.user_id = $1
) t"#,
user_id,
)
.await
}
@@ -3,6 +3,7 @@
pub mod analytics; pub mod analytics;
pub mod cards; pub mod cards;
pub mod export;
pub mod sessions; pub mod sessions;
pub mod share; pub mod share;
pub mod teams; pub mod teams;
@@ -2,9 +2,109 @@
use uuid::Uuid; use uuid::Uuid;
use crate::models::team::{MemberRow, TeamRow}; use crate::models::team::{MemberRow, TeamCardStats, TeamRow, TeamTemplateRow};
use crate::Db; use crate::Db;
pub async fn insert_template(
db: &Db,
team_id: Uuid,
name: &str,
definition: &serde_json::Value,
created_by: Uuid,
) -> Result<TeamTemplateRow, sqlx::Error> {
sqlx::query_as(
r#"INSERT INTO team_templates (team_id, name, definition, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, team_id, name, definition, created_by, created_at"#,
)
.bind(team_id)
.bind(name)
.bind(definition)
.bind(created_by)
.fetch_one(db)
.await
}
pub async fn list_templates(db: &Db, team_id: Uuid) -> Result<Vec<TeamTemplateRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, team_id, name, definition, created_by, created_at
FROM team_templates WHERE team_id = $1 ORDER BY created_at ASC"#,
)
.bind(team_id)
.fetch_all(db)
.await
}
pub async fn delete_template(
db: &Db,
team_id: Uuid,
template_id: Uuid,
) -> Result<u64, sqlx::Error> {
let r = sqlx::query(r#"DELETE FROM team_templates WHERE team_id = $1 AND id = $2"#)
.bind(team_id)
.bind(template_id)
.execute(db)
.await?;
Ok(r.rows_affected())
}
/// Sortable columns for team analytics. Whitelisted so the caller's sort key can
/// never be interpolated into SQL directly.
#[derive(Debug, Clone, Copy)]
pub enum TeamStatsSort {
Visits,
QrScans,
ContactSaves,
LinkClicks,
}
impl TeamStatsSort {
pub fn parse(s: Option<&str>) -> Self {
match s {
Some("qr_scans") => TeamStatsSort::QrScans,
Some("contact_saves") => TeamStatsSort::ContactSaves,
Some("link_clicks") => TeamStatsSort::LinkClicks,
_ => TeamStatsSort::Visits,
}
}
fn column(self) -> &'static str {
match self {
TeamStatsSort::Visits => "visits",
TeamStatsSort::QrScans => "qr_scans",
TeamStatsSort::ContactSaves => "contact_saves",
TeamStatsSort::LinkClicks => "link_clicks",
}
}
}
/// Per-card aggregate stats across all of a team's members (PRD §6.7.3).
/// Sorted descending by the chosen (whitelisted) metric.
pub async fn team_card_stats(
db: &Db,
team_id: Uuid,
sort: TeamStatsSort,
) -> Result<Vec<TeamCardStats>, sqlx::Error> {
// The ORDER BY column comes from the whitelist above — never user input.
let sql = format!(
r#"
SELECT c.id AS card_id, c.handle, u.display_name AS owner_display_name,
COUNT(e.id) FILTER (WHERE e.event_type = 'profile_visit') AS visits,
COUNT(e.id) FILTER (WHERE e.event_type = 'qr_scan') AS qr_scans,
COUNT(e.id) FILTER (WHERE e.event_type = 'contact_save') AS contact_saves,
COUNT(e.id) FILTER (WHERE e.event_type = 'link_click') AS link_clicks
FROM cards c
JOIN team_memberships m ON m.user_id = c.owner_id AND m.team_id = $1
JOIN users u ON u.id = c.owner_id
LEFT JOIN analytics_events e ON e.card_id = c.id
GROUP BY c.id, c.handle, u.display_name
ORDER BY {} DESC, c.handle ASC
"#,
sort.column()
);
sqlx::query_as(&sql).bind(team_id).fetch_all(db).await
}
pub async fn insert_team( pub async fn insert_team(
db: &Db, db: &Db,
name: &str, name: &str,
@@ -57,6 +57,17 @@ pub async fn update_tier(db: &Db, id: Uuid, tier: &str) -> Result<u64, sqlx::Err
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
/// Hard-delete a user. FK cascades remove their cards (and the cards' share
/// links, analytics events, wallet registrations), sessions, team memberships,
/// and any team they own (PRD §18.3).
pub async fn delete(db: &Db, id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(db)
.await?;
Ok(r.rows_affected())
}
pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<User>, sqlx::Error> { pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<User>, sqlx::Error> {
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
r#"SELECT id, email, handle, display_name, tier, password_hash, r#"SELECT id, email, handle, display_name, tier, password_hash,