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
@@ -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>,
}
/// 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.
#[derive(Debug, Clone, FromRow, Serialize)]
#[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 cards;
pub mod export;
pub mod sessions;
pub mod share;
pub mod teams;
@@ -2,9 +2,109 @@
use uuid::Uuid;
use crate::models::team::{MemberRow, TeamRow};
use crate::models::team::{MemberRow, TeamCardStats, TeamRow, TeamTemplateRow};
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(
db: &Db,
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())
}
/// 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> {
let row: Option<UserRow> = sqlx::query_as(
r#"SELECT id, email, handle, display_name, tier, password_hash,