srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server/src/cosmetics.rs
blob: ee9a50d1d08717f8e87ba14e9811a0a41dd5e590 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
use crate::auth::{current_user, format_timestamp};
use time::OffsetDateTime;
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use axum_extra::extract::cookie::CookieJar;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::sync::Arc;

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/api/cosmetics", get(list_catalog))
        .route("/api/cosmetics/me", get(my_cosmetics))
        .route("/api/cosmetics/bundles", get(list_bundles))
        .route("/api/merch", get(list_merch))
        .route("/api/cosmetics/:id/equip", post(equip))
        .route("/api/cosmetics/unequip", post(unequip))
}

#[derive(Debug, Serialize)]
struct Cosmetic {
    id: String,
    name: String,
    category: String,
    price_cents: i32,
    value: String,
}

async fn list_catalog(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
    let rows = sqlx::query("SELECT id, name, category, price_cents, value FROM cosmetics ORDER BY category, price_cents")
        .fetch_all(&state.db)
        .await?;
    // Decode errors are returned, not defaulted away. price_cents was typed
    // i64 against an INTEGER column, so sqlx refused every decode and
    // unwrap_or_default turned the whole catalogue into $0.00 with nothing
    // logged. A wrong price is worse than an error page.
    let items: Vec<Cosmetic> = rows
        .into_iter()
        .map(|row| {
            Ok(Cosmetic {
                id: row.try_get("id")?,
                name: row.try_get("name")?,
                category: row.try_get("category")?,
                price_cents: row.try_get("price_cents")?,
                value: row.try_get("value")?,
            })
        })
        .collect::<Result<_, sqlx::Error>>()?;
    Ok(Json(items))
}

#[derive(Debug, Serialize)]
struct MyCosmetics {
    owned: Vec<String>,
    equipped_caret: Option<String>,
    equipped_flair: Option<String>,
    equipped_sprite: Option<String>,
    is_supporter: bool,
}

/// What is for sale in the physical store.
async fn list_merch(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
    let rows = sqlx::query(
        "SELECT id, name, description, price_cents, kind, variants, shipping_cents
         FROM merch WHERE available ORDER BY sort_order",
    )
    .fetch_all(&state.db)
    .await?;

    let items: Vec<serde_json::Value> = rows
        .iter()
        .map(|r| {
            let variants: Vec<String> = r.try_get("variants").unwrap_or_default();
            serde_json::json!({
                "id": r.try_get::<String, _>("id").unwrap_or_default(),
                "name": r.try_get::<String, _>("name").unwrap_or_default(),
                "description": r.try_get::<Option<String>, _>("description").unwrap_or(None),
                "price_cents": r.try_get::<i32, _>("price_cents").unwrap_or(0),
                "shipping_cents": r.try_get::<i32, _>("shipping_cents").unwrap_or(0),
                "kind": r.try_get::<String, _>("kind").unwrap_or_default(),
                "variants": variants,
            })
        })
        .collect();

    Ok(Json(items))
}

/// Bundles, with the items each contains so the store can show what is in one
/// and what the buyer already owns.
async fn list_bundles(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
    let rows = sqlx::query(
        "SELECT b.id, b.name, b.description, b.price_cents,
                COALESCE(SUM(c.price_cents), 0) AS full_price,
                COALESCE(ARRAY_AGG(c.id ORDER BY c.id) FILTER (WHERE c.id IS NOT NULL), '{}') AS items
         FROM bundles b
         LEFT JOIN bundle_items bi ON bi.bundle_id = b.id
         LEFT JOIN cosmetics c ON c.id = bi.cosmetic_id
         GROUP BY b.id, b.name, b.description, b.price_cents, b.sort_order
         ORDER BY b.sort_order",
    )
    .fetch_all(&state.db)
    .await?;

    let bundles: Vec<serde_json::Value> = rows
        .iter()
        .map(|r| {
            let items: Vec<String> = r.try_get("items").unwrap_or_default();
            serde_json::json!({
                "id": r.try_get::<String, _>("id").unwrap_or_default(),
                "name": r.try_get::<String, _>("name").unwrap_or_default(),
                "description": r.try_get::<Option<String>, _>("description").unwrap_or(None),
                "price_cents": r.try_get::<i32, _>("price_cents").unwrap_or(0),
                // What the same items cost bought one at a time, so the store
                // can show the saving rather than asserting one.
                "full_price_cents": r.try_get::<i64, _>("full_price").unwrap_or(0),
                "items": items,
            })
        })
        .collect();

    Ok(Json(bundles))
}

async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> {
    let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;

    let owned_rows = sqlx::query("SELECT cosmetic_id FROM user_cosmetics WHERE user_id = $1")
        .bind(&user.id)
        .fetch_all(&state.db)
        .await?;
    let owned: Vec<String> = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect();

    // is_supporter is computed from the expiry here for the same reason it is
    // in /api/auth/me: the stored flag is set on payment and never cleared.
    let equip_row = sqlx::query(
        "SELECT equipped_caret, equipped_flair, equipped_sprite,
                (is_supporter AND supporter_until IS NOT NULL AND supporter_until > $2) AS active_supporter
         FROM users WHERE id = $1",
    )
    .bind(&user.id)
    .bind(format_timestamp(OffsetDateTime::now_utc()))
    .fetch_one(&state.db)
    .await?;

    Ok(Json(MyCosmetics {
        owned,
        // Explicit Option<String> turbofish - try_get(...).ok() here was
        // ambiguous enough that type inference picked plain String, and
        // sqlx's SQLite decode of a NULL column into String silently
        // produced an empty string instead of erroring the way decoding
        // into Option<String> correctly does. Found via a real NULL column
        // round-tripping as "" instead of JSON null.
        equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None),
        equipped_sprite: equip_row.try_get::<Option<String>, _>("equipped_sprite").unwrap_or(None),
        equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None),
        is_supporter: equip_row.try_get::<Option<bool>, _>("active_supporter").unwrap_or(None).unwrap_or(false),
    }))
}

async fn equip(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> {
    let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;

    let row = sqlx::query(
        "SELECT cosmetics.category as category FROM cosmetics
         JOIN user_cosmetics ON user_cosmetics.cosmetic_id = cosmetics.id
         WHERE cosmetics.id = $1 AND user_cosmetics.user_id = $2",
    )
    .bind(&cosmetic_id)
    .bind(&user.id)
    .fetch_optional(&state.db)
    .await?
    .ok_or(AppError::InvalidInput("you don't own this cosmetic".into()))?;

    let category: String = row.try_get("category").map_err(|e| AppError::Internal(e.into()))?;
    let column = match category.as_str() {
        "caret" => "equipped_caret",
        "flair" => "equipped_flair",
        "sprite" => "equipped_sprite",
        _ => return Err(AppError::Internal(anyhow::anyhow!("unknown cosmetic category"))),
    };

    // Column name comes from a hardcoded match above, never from request
    // input, so this is safe despite not being a bind parameter.
    let query = format!("UPDATE users SET {column} = $1 WHERE id = $2");
    sqlx::query(&query).bind(&cosmetic_id).bind(&user.id).execute(&state.db).await?;

    Ok(axum::http::StatusCode::NO_CONTENT)
}

#[derive(Debug, Deserialize)]
struct UnequipRequest {
    category: String,
}

async fn unequip(State(state): State<Arc<AppState>>, jar: CookieJar, Json(body): Json<UnequipRequest>) -> Result<impl IntoResponse, AppError> {
    let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
    let column = match body.category.as_str() {
        "caret" => "equipped_caret",
        "flair" => "equipped_flair",
        "sprite" => "equipped_sprite",
        _ => return Err(AppError::InvalidInput("category must be 'caret', 'flair' or 'sprite'".into())),
    };
    let query = format!("UPDATE users SET {column} = NULL WHERE id = $1");
    sqlx::query(&query).bind(&user.id).execute(&state.db).await?;
    Ok(axum::http::StatusCode::NO_CONTENT)
}