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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user