Phase 4 + GDPR: team analytics, central templates, account export/deletion
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:
co-authored by
Claude Opus 4.8
parent
2b8e23dd3f
commit
826ef69030
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user