Merge pull request #23 from stevedylandev/chore/refactor-cellar a6812266
chore/refactor cellar
Steve Simkins · 2026-04-16 07:53 5 file(s) · +259 −244
Cargo.lock +11 −0
693 693
 "rust-embed",
694 694
 "serde",
695 695
 "serde_json",
696 +
 "serde_rusqlite",
696 697
 "subtle",
697 698
 "tokio",
698 699
 "tracing",
4031 4032
dependencies = [
4032 4033
 "itoa",
4033 4034
 "serde",
4035 +
 "serde_core",
4036 +
]
4037 +
4038 +
[[package]]
4039 +
name = "serde_rusqlite"
4040 +
version = "0.41.1"
4041 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4042 +
checksum = "5224145f7ea2188165a3ad4e856c4452474683cd7a72dab3325201f673a2b410"
4043 +
dependencies = [
4044 +
 "rusqlite",
4034 4045
 "serde_core",
4035 4046
]
4036 4047
apps/cellar/Cargo.toml +1 −0
26 26
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
27 27
base64 = "0.22"
28 28
image = "0.25"
29 +
serde_rusqlite = "0.41"
apps/cellar/src/db.rs +75 −80
99 99
}
100 100
101 101
fn wine_from_row(row: &rusqlite::Row) -> rusqlite::Result<Wine> {
102 -
    Ok(Wine {
103 -
        id: row.get(0)?,
104 -
        short_id: row.get(1)?,
105 -
        name: row.get(2)?,
106 -
        origin: row.get(3)?,
107 -
        grape: row.get(4)?,
108 -
        notes: row.get(5)?,
109 -
        has_image: row.get(6)?,
110 -
        image_mime: row.get(7)?,
111 -
        sweetness: row.get(8)?,
112 -
        acidity: row.get(9)?,
113 -
        tannin: row.get(10)?,
114 -
        alcohol: row.get(11)?,
115 -
        body: row.get(12)?,
116 -
        clarity: row.get(13)?,
117 -
        color_intensity: row.get(14)?,
118 -
        aroma_intensity: row.get(15)?,
119 -
        nose_complexity: row.get(16)?,
120 -
        background: row.get(17)?,
121 -
        created_at: row.get(18)?,
122 -
        wishlist: row.get::<_, i32>(19)? != 0,
102 +
    serde_rusqlite::from_row::<Wine>(row).map_err(|e| {
103 +
        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e))
123 104
    })
124 105
}
125 106
126 107
const WINE_COLUMNS: &str =
127 108
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist";
128 109
129 -
pub fn create_wine(
130 -
    db: &Db,
131 -
    name: &str,
132 -
    origin: &str,
133 -
    grape: &str,
134 -
    notes: &str,
135 -
    image: Option<&[u8]>,
136 -
    image_mime: Option<&str>,
137 -
    sweetness: i32,
138 -
    acidity: i32,
139 -
    tannin: i32,
140 -
    alcohol: i32,
141 -
    body: i32,
142 -
    clarity: i32,
143 -
    color_intensity: i32,
144 -
    aroma_intensity: i32,
145 -
    nose_complexity: i32,
146 -
    background: &str,
147 -
    wishlist: bool,
148 -
) -> Result<Wine, DbError> {
110 +
#[derive(Serialize)]
111 +
pub struct WineInput<'a> {
112 +
    pub name: &'a str,
113 +
    pub origin: &'a str,
114 +
    pub grape: &'a str,
115 +
    pub notes: &'a str,
116 +
    pub sweetness: i32,
117 +
    pub acidity: i32,
118 +
    pub tannin: i32,
119 +
    pub alcohol: i32,
120 +
    pub body: i32,
121 +
    pub clarity: i32,
122 +
    pub color_intensity: i32,
123 +
    pub aroma_intensity: i32,
124 +
    pub nose_complexity: i32,
125 +
    pub background: &'a str,
126 +
}
127 +
128 +
pub fn create_wine(db: &Db, input: &WineInput, wishlist: bool) -> Result<Wine, DbError> {
149 129
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
150 130
    let short_id = nanoid!(10);
151 -
    let wishlist_int: i32 = if wishlist { 1 } else { 0 };
131 +
    let named = serde_rusqlite::to_params_named(input)
132 +
        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
133 +
    let mut bindings = named.to_slice();
134 +
    bindings.push((":short_id", &short_id));
135 +
    bindings.push((":wishlist", &wishlist));
152 136
    conn.execute(
153 -
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist)
154 -
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
155 -
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist_int],
137 +
        "INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist)
138 +
         VALUES (:short_id, :name, :origin, :grape, :notes, :sweetness, :acidity, :tannin, :alcohol, :body, :clarity, :color_intensity, :aroma_intensity, :nose_complexity, :background, :wishlist)",
139 +
        bindings.as_slice(),
156 140
    )?;
157 141
    let id = conn.last_insert_rowid();
158 142
    let wine = conn.query_row(
263 247
pub fn update_wine(
264 248
    db: &Db,
265 249
    short_id: &str,
266 -
    name: &str,
267 -
    origin: &str,
268 -
    grape: &str,
269 -
    notes: &str,
270 -
    sweetness: i32,
271 -
    acidity: i32,
272 -
    tannin: i32,
273 -
    alcohol: i32,
274 -
    body: i32,
275 -
    clarity: i32,
276 -
    color_intensity: i32,
277 -
    aroma_intensity: i32,
278 -
    nose_complexity: i32,
279 -
    background: &str,
250 +
    input: &WineInput,
280 251
) -> Result<Option<Wine>, DbError> {
281 252
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
253 +
    let named = serde_rusqlite::to_params_named(input)
254 +
        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
255 +
    let mut bindings = named.to_slice();
256 +
    bindings.push((":short_id", &short_id));
282 257
    let rows = conn.execute(
283 -
        "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, clarity = ?10, color_intensity = ?11, aroma_intensity = ?12, nose_complexity = ?13, background = ?14 WHERE short_id = ?15",
284 -
        params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, short_id],
258 +
        "UPDATE wines SET name = :name, origin = :origin, grape = :grape, notes = :notes, sweetness = :sweetness, acidity = :acidity, tannin = :tannin, alcohol = :alcohol, body = :body, clarity = :clarity, color_intensity = :color_intensity, aroma_intensity = :aroma_intensity, nose_complexity = :nose_complexity, background = :background WHERE short_id = :short_id",
259 +
        bindings.as_slice(),
285 260
    )?;
286 261
    if rows == 0 {
287 262
        return Ok(None);
401 376
        Arc::new(Mutex::new(conn))
402 377
    }
403 378
379 +
    fn sample_input<'a>(name: &'a str, sweetness: i32) -> WineInput<'a> {
380 +
        WineInput {
381 +
            name,
382 +
            origin: "France",
383 +
            grape: "Merlot",
384 +
            notes: "Smooth",
385 +
            sweetness,
386 +
            acidity: 3,
387 +
            tannin: 3,
388 +
            alcohol: 3,
389 +
            body: 3,
390 +
            clarity: 3,
391 +
            color_intensity: 3,
392 +
            aroma_intensity: 3,
393 +
            nose_complexity: 3,
394 +
            background: "",
395 +
        }
396 +
    }
397 +
404 398
    fn create_test_wine(db: &Db, name: &str, wishlist: bool) -> Wine {
405 -
        create_wine(
406 -
            db, name, "France", "Merlot", "Smooth", None, None,
407 -
            3, 3, 3, 3, 3, 3, 3, 3, 3, "", wishlist,
408 -
        )
409 -
        .unwrap()
399 +
        create_wine(db, &sample_input(name, 3), wishlist).unwrap()
410 400
    }
411 401
412 402
    // ── Wine CRUD ──────────────────────────────────────────────────────
426 416
    #[test]
427 417
    fn create_wine_invalid_sweetness_fails() {
428 418
        let db = test_db();
429 -
        let result = create_wine(
430 -
            &db, "Bad", "X", "X", "X", None, None,
431 -
            6, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=6 > 5
432 -
        );
419 +
        let result = create_wine(&db, &sample_input("Bad", 6), false);
433 420
        assert!(result.is_err());
434 421
    }
435 422
436 423
    #[test]
437 424
    fn create_wine_zero_rating_fails() {
438 425
        let db = test_db();
439 -
        let result = create_wine(
440 -
            &db, "Bad", "X", "X", "X", None, None,
441 -
            0, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=0 < 1
442 -
        );
426 +
        let result = create_wine(&db, &sample_input("Bad", 0), false);
443 427
        assert!(result.is_err());
444 428
    }
445 429
491 475
        let db = test_db();
492 476
        let wine = create_test_wine(&db, "Old Name", false);
493 477
494 -
        let updated = update_wine(
495 -
            &db, &wine.short_id, "New Name", "Italy", "Sangiovese", "Bold",
496 -
            4, 4, 4, 4, 4, 4, 4, 4, 4, "deep red",
497 -
        )
498 -
        .unwrap()
499 -
        .unwrap();
478 +
        let input = WineInput {
479 +
            name: "New Name",
480 +
            origin: "Italy",
481 +
            grape: "Sangiovese",
482 +
            notes: "Bold",
483 +
            sweetness: 4,
484 +
            acidity: 4,
485 +
            tannin: 4,
486 +
            alcohol: 4,
487 +
            body: 4,
488 +
            clarity: 4,
489 +
            color_intensity: 4,
490 +
            aroma_intensity: 4,
491 +
            nose_complexity: 4,
492 +
            background: "deep red",
493 +
        };
494 +
        let updated = update_wine(&db, &wine.short_id, &input).unwrap().unwrap();
500 495
501 496
        assert_eq!(updated.name, "New Name");
502 497
        assert_eq!(updated.origin, "Italy");
apps/cellar/src/server/handlers/admin.rs +39 −54
136 136
        }
137 137
    };
138 138
139 -
    match db::create_wine(
140 -
        &state.db,
141 -
        &data.name,
142 -
        &data.origin,
143 -
        &data.grape,
144 -
        &data.notes,
145 -
        data.image.as_deref(),
146 -
        data.image_mime.as_deref(),
147 -
        data.sweetness,
148 -
        data.acidity,
149 -
        data.tannin,
150 -
        data.alcohol,
151 -
        data.body,
152 -
        data.clarity,
153 -
        data.color_intensity,
154 -
        data.aroma_intensity,
155 -
        data.nose_complexity,
156 -
        &data.background,
157 -
        false,
158 -
    ) {
159 -
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
139 +
    let input = db::WineInput::from(&data);
140 +
    match db::create_wine(&state.db, &input, false) {
141 +
        Ok(wine) => {
142 +
            if let (Some(image), Some(mime)) = (&data.base.image, &data.base.image_mime) {
143 +
                if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) {
144 +
                    tracing::error!("Failed to set wine image: {}", e);
145 +
                }
146 +
            }
147 +
            Redirect::to(&format!("/wines/{}", wine.short_id)).into_response()
148 +
        }
160 149
        Err(e) => {
161 150
            tracing::error!("Failed to create wine: {}", e);
162 151
            Redirect::to("/admin/new?error=Failed+to+create+wine").into_response()
182 171
        }
183 172
    };
184 173
185 -
    match db::update_wine(
186 -
        &state.db,
187 -
        &short_id,
188 -
        &data.name,
189 -
        &data.origin,
190 -
        &data.grape,
191 -
        &data.notes,
192 -
        data.sweetness,
193 -
        data.acidity,
194 -
        data.tannin,
195 -
        data.alcohol,
196 -
        data.body,
197 -
        data.clarity,
198 -
        data.color_intensity,
199 -
        data.aroma_intensity,
200 -
        data.nose_complexity,
201 -
        &data.background,
202 -
    ) {
174 +
    let input = db::WineInput::from(&data);
175 +
    match db::update_wine(&state.db, &short_id, &input) {
203 176
        Ok(Some(_)) => {
204 -
            if let Some(image) = &data.image {
205 -
                if let Some(mime) = &data.image_mime {
177 +
            if let Some(image) = &data.base.image {
178 +
                if let Some(mime) = &data.base.image_mime {
206 179
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
207 180
                        tracing::error!("Failed to update wine image: {}", e);
208 181
                    }
285 258
        }
286 259
    };
287 260
288 -
    match db::create_wine(
289 -
        &state.db,
290 -
        &data.name,
291 -
        &data.origin,
292 -
        &data.grape,
293 -
        &data.notes,
294 -
        data.image.as_deref(),
295 -
        data.image_mime.as_deref(),
296 -
        3, 3, 3, 3, 3, 3, 3, 3, 3,
297 -
        &data.background,
298 -
        true,
299 -
    ) {
300 -
        Ok(_) => Redirect::to("/wishlist").into_response(),
261 +
    let input = db::WineInput {
262 +
        name: &data.name,
263 +
        origin: &data.origin,
264 +
        grape: &data.grape,
265 +
        notes: &data.notes,
266 +
        sweetness: 3,
267 +
        acidity: 3,
268 +
        tannin: 3,
269 +
        alcohol: 3,
270 +
        body: 3,
271 +
        clarity: 3,
272 +
        color_intensity: 3,
273 +
        aroma_intensity: 3,
274 +
        nose_complexity: 3,
275 +
        background: &data.background,
276 +
    };
277 +
    match db::create_wine(&state.db, &input, true) {
278 +
        Ok(wine) => {
279 +
            if let (Some(image), Some(mime)) = (&data.image, &data.image_mime) {
280 +
                if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) {
281 +
                    tracing::error!("Failed to set wine image: {}", e);
282 +
                }
283 +
            }
284 +
            Redirect::to("/wishlist").into_response()
285 +
        }
301 286
        Err(e) => {
302 287
            tracing::error!("Failed to create wishlist wine: {}", e);
303 288
            Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response()
apps/cellar/src/server/mod.rs +133 −110
1 1
use askama::Template;
2 2
use axum::{
3 -
    extract::{DefaultBodyLimit, Multipart},
3 +
    extract::{multipart::Field, DefaultBodyLimit, Multipart},
4 4
    routing::{get, post},
5 5
    Router,
6 6
};
320 320
321 321
// --- Multipart parsing ---
322 322
323 -
struct WineFormData {
323 +
#[derive(Default)]
324 +
struct WineBase {
324 325
    name: String,
325 326
    origin: String,
326 327
    grape: String,
328 329
    background: String,
329 330
    image: Option<Vec<u8>>,
330 331
    image_mime: Option<String>,
332 +
}
333 +
334 +
impl WineBase {
335 +
    fn owns(field_name: &str) -> bool {
336 +
        matches!(
337 +
            field_name,
338 +
            "image" | "name" | "origin" | "grape" | "notes" | "background"
339 +
        )
340 +
    }
341 +
342 +
    async fn apply_field(&mut self, field_name: &str, field: Field<'_>) -> Result<(), String> {
343 +
        match field_name {
344 +
            "image" => {
345 +
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
346 +
                if !bytes.is_empty() {
347 +
                    self.image = Some(process_image(&bytes)?);
348 +
                    self.image_mime = Some("image/jpeg".to_string());
349 +
                }
350 +
            }
351 +
            "name" => self.name = field.text().await.unwrap_or_default(),
352 +
            "origin" => self.origin = field.text().await.unwrap_or_default(),
353 +
            "grape" => self.grape = field.text().await.unwrap_or_default(),
354 +
            "notes" => self.notes = field.text().await.unwrap_or_default(),
355 +
            "background" => self.background = field.text().await.unwrap_or_default(),
356 +
            _ => {}
357 +
        }
358 +
        Ok(())
359 +
    }
360 +
361 +
    fn finalize(&mut self) -> Result<(), String> {
362 +
        if self.name.trim().is_empty() {
363 +
            return Err("Name is required".to_string());
364 +
        }
365 +
        self.name = self.name.trim().to_string();
366 +
        self.origin = self.origin.trim().to_string();
367 +
        self.grape = self.grape.trim().to_string();
368 +
        self.notes = self.notes.trim().to_string();
369 +
        self.background = self.background.trim().to_string();
370 +
        Ok(())
371 +
    }
372 +
}
373 +
374 +
type WishlistFormData = WineBase;
375 +
376 +
struct WineScores {
331 377
    sweetness: i32,
332 378
    acidity: i32,
333 379
    tannin: i32,
339 385
    nose_complexity: i32,
340 386
}
341 387
342 -
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
343 -
    let mut name = String::new();
344 -
    let mut origin = String::new();
345 -
    let mut grape = String::new();
346 -
    let mut notes = String::new();
347 -
    let mut background = String::new();
348 -
    let mut image: Option<Vec<u8>> = None;
349 -
    let mut image_mime: Option<String> = None;
350 -
    let mut sweetness = 3;
351 -
    let mut acidity = 3;
352 -
    let mut tannin = 3;
353 -
    let mut alcohol = 3;
354 -
    let mut body = 3;
355 -
    let mut clarity = 3;
356 -
    let mut color_intensity = 3;
357 -
    let mut aroma_intensity = 3;
358 -
    let mut nose_complexity = 3;
388 +
impl Default for WineScores {
389 +
    fn default() -> Self {
390 +
        Self {
391 +
            sweetness: 3,
392 +
            acidity: 3,
393 +
            tannin: 3,
394 +
            alcohol: 3,
395 +
            body: 3,
396 +
            clarity: 3,
397 +
            color_intensity: 3,
398 +
            aroma_intensity: 3,
399 +
            nose_complexity: 3,
400 +
        }
401 +
    }
402 +
}
359 403
360 -
    while let Ok(Some(field)) = multipart.next_field().await {
361 -
        let field_name = field.name().unwrap_or("").to_string();
362 -
        match field_name.as_str() {
363 -
            "image" => {
364 -
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
365 -
                if !bytes.is_empty() {
366 -
                    let processed = process_image(&bytes)?;
367 -
                    image = Some(processed);
368 -
                    image_mime = Some("image/jpeg".to_string());
369 -
                }
370 -
            }
371 -
            "name" => name = field.text().await.unwrap_or_default(),
372 -
            "origin" => origin = field.text().await.unwrap_or_default(),
373 -
            "grape" => grape = field.text().await.unwrap_or_default(),
374 -
            "notes" => notes = field.text().await.unwrap_or_default(),
375 -
            "background" => background = field.text().await.unwrap_or_default(),
376 -
            "sweetness" => sweetness = field.text().await.unwrap_or_default().parse().unwrap_or(3),
377 -
            "acidity" => acidity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
378 -
            "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3),
379 -
            "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3),
380 -
            "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3),
381 -
            "clarity" => clarity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
382 -
            "color_intensity" => color_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
383 -
            "aroma_intensity" => aroma_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
384 -
            "nose_complexity" => nose_complexity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
385 -
            _ => {}
386 -
        }
404 +
impl WineScores {
405 +
    fn slot(&mut self, field_name: &str) -> Option<&mut i32> {
406 +
        Some(match field_name {
407 +
            "sweetness" => &mut self.sweetness,
408 +
            "acidity" => &mut self.acidity,
409 +
            "tannin" => &mut self.tannin,
410 +
            "alcohol" => &mut self.alcohol,
411 +
            "body" => &mut self.body,
412 +
            "clarity" => &mut self.clarity,
413 +
            "color_intensity" => &mut self.color_intensity,
414 +
            "aroma_intensity" => &mut self.aroma_intensity,
415 +
            "nose_complexity" => &mut self.nose_complexity,
416 +
            _ => return None,
417 +
        })
387 418
    }
388 419
389 -
    if name.trim().is_empty() {
390 -
        return Err("Name is required".to_string());
420 +
    fn clamp_all(&mut self) {
421 +
        for v in [
422 +
            &mut self.sweetness,
423 +
            &mut self.acidity,
424 +
            &mut self.tannin,
425 +
            &mut self.alcohol,
426 +
            &mut self.body,
427 +
            &mut self.clarity,
428 +
            &mut self.color_intensity,
429 +
            &mut self.aroma_intensity,
430 +
            &mut self.nose_complexity,
431 +
        ] {
432 +
            *v = (*v).clamp(1, 5);
433 +
        }
391 434
    }
435 +
}
392 436
393 -
    let clamp = |v: i32| v.max(1).min(5);
394 -
    Ok(WineFormData {
395 -
        name: name.trim().to_string(),
396 -
        origin: origin.trim().to_string(),
397 -
        grape: grape.trim().to_string(),
398 -
        notes: notes.trim().to_string(),
399 -
        background: background.trim().to_string(),
400 -
        image,
401 -
        image_mime,
402 -
        sweetness: clamp(sweetness),
403 -
        acidity: clamp(acidity),
404 -
        tannin: clamp(tannin),
405 -
        alcohol: clamp(alcohol),
406 -
        body: clamp(body),
407 -
        clarity: clamp(clarity),
408 -
        color_intensity: clamp(color_intensity),
409 -
        aroma_intensity: clamp(aroma_intensity),
410 -
        nose_complexity: clamp(nose_complexity),
411 -
    })
437 +
struct WineFormData {
438 +
    base: WineBase,
439 +
    scores: WineScores,
412 440
}
413 441
414 -
struct WishlistFormData {
415 -
    name: String,
416 -
    origin: String,
417 -
    grape: String,
418 -
    notes: String,
419 -
    background: String,
420 -
    image: Option<Vec<u8>>,
421 -
    image_mime: Option<String>,
442 +
impl<'a> From<&'a WineFormData> for crate::db::WineInput<'a> {
443 +
    fn from(data: &'a WineFormData) -> Self {
444 +
        Self {
445 +
            name: &data.base.name,
446 +
            origin: &data.base.origin,
447 +
            grape: &data.base.grape,
448 +
            notes: &data.base.notes,
449 +
            sweetness: data.scores.sweetness,
450 +
            acidity: data.scores.acidity,
451 +
            tannin: data.scores.tannin,
452 +
            alcohol: data.scores.alcohol,
453 +
            body: data.scores.body,
454 +
            clarity: data.scores.clarity,
455 +
            color_intensity: data.scores.color_intensity,
456 +
            aroma_intensity: data.scores.aroma_intensity,
457 +
            nose_complexity: data.scores.nose_complexity,
458 +
            background: &data.base.background,
459 +
        }
460 +
    }
422 461
}
423 462
424 -
async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> {
425 -
    let mut name = String::new();
426 -
    let mut origin = String::new();
427 -
    let mut grape = String::new();
428 -
    let mut notes = String::new();
429 -
    let mut background = String::new();
430 -
    let mut image: Option<Vec<u8>> = None;
431 -
    let mut image_mime: Option<String> = None;
463 +
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
464 +
    let mut base = WineBase::default();
465 +
    let mut scores = WineScores::default();
432 466
433 467
    while let Ok(Some(field)) = multipart.next_field().await {
434 468
        let field_name = field.name().unwrap_or("").to_string();
435 -
        match field_name.as_str() {
436 -
            "image" => {
437 -
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
438 -
                if !bytes.is_empty() {
439 -
                    let processed = process_image(&bytes)?;
440 -
                    image = Some(processed);
441 -
                    image_mime = Some("image/jpeg".to_string());
442 -
                }
443 -
            }
444 -
            "name" => name = field.text().await.unwrap_or_default(),
445 -
            "origin" => origin = field.text().await.unwrap_or_default(),
446 -
            "grape" => grape = field.text().await.unwrap_or_default(),
447 -
            "notes" => notes = field.text().await.unwrap_or_default(),
448 -
            "background" => background = field.text().await.unwrap_or_default(),
449 -
            _ => {}
469 +
        if WineBase::owns(&field_name) {
470 +
            base.apply_field(&field_name, field).await?;
471 +
        } else if let Some(slot) = scores.slot(&field_name) {
472 +
            *slot = field.text().await.unwrap_or_default().parse().unwrap_or(3);
450 473
        }
451 474
    }
452 475
453 -
    if name.trim().is_empty() {
454 -
        return Err("Name is required".to_string());
455 -
    }
476 +
    base.finalize()?;
477 +
    scores.clamp_all();
478 +
    Ok(WineFormData { base, scores })
479 +
}
456 480
457 -
    Ok(WishlistFormData {
458 -
        name: name.trim().to_string(),
459 -
        origin: origin.trim().to_string(),
460 -
        grape: grape.trim().to_string(),
461 -
        notes: notes.trim().to_string(),
462 -
        background: background.trim().to_string(),
463 -
        image,
464 -
        image_mime,
465 -
    })
481 +
async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> {
482 +
    let mut base = WineBase::default();
483 +
    while let Ok(Some(field)) = multipart.next_field().await {
484 +
        let field_name = field.name().unwrap_or("").to_string();
485 +
        base.apply_field(&field_name, field).await?;
486 +
    }
487 +
    base.finalize()?;
488 +
    Ok(base)
466 489
}
467 490
468 491
// --- Router ---