add reward card feature with barcodes

This commit is contained in:
2026-08-08 15:55:18 -04:00
parent ab46e63bf0
commit 975cf38170
13 changed files with 771 additions and 11 deletions
+75 -1
View File
@@ -23,7 +23,9 @@ use tracing::{Level, error, warn};
use crate::assets;
use crate::domain::{DomainError, SessionUser};
use crate::ports::{HubEvent, RealtimeNotifier};
use crate::services::{AuthService, InvitationService, ListService, MealService};
use crate::services::{
AuthService, InvitationService, ListService, MealService, RewardsCardService,
};
use crate::views;
use crate::webauthn::WebAuthnService;
@@ -33,6 +35,7 @@ pub struct AppState {
pub lists: Arc<ListService>,
pub meals: Arc<MealService>,
pub invitations: Arc<InvitationService>,
pub rewards_cards: Arc<RewardsCardService>,
pub webauthn: Arc<WebAuthnService>,
pub realtime: Arc<dyn RealtimeNotifier>,
pub cookie_secure: bool,
@@ -130,6 +133,8 @@ pub fn build_router(state: AppState) -> Router {
"/lists/{list_id}/meals/{list_meal_id}/remove",
post(remove_meal_from_list),
)
.route("/rewards", get(rewards_page).post(create_rewards_card))
.route("/rewards/{card_id}/delete", post(delete_rewards_card))
.route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation))
@@ -261,6 +266,14 @@ struct CategoryForm {
csrf: String,
}
#[derive(Debug, Deserialize)]
struct RewardsCardForm {
store_name: String,
number: String,
symbology: String,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct PasskeyRegisterStartForm {
csrf: String,
@@ -819,6 +832,67 @@ async fn delete_meal_category(
Ok(Redirect::to("/meals").into_response())
}
async fn rewards_page(
State(state): State<AppState>,
user: CurrentUser,
) -> Result<Response, AppError> {
let cards = state.rewards_cards.list_cards(user.session.user.id).await?;
Ok(html_response(views::rewards_page(
&user.session.user,
&cards,
&user.session.csrf_token,
)))
}
async fn create_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
LoggedForm(form): LoggedForm<RewardsCardForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let store_name = form.store_name.trim().to_owned();
let number = form.number.trim().to_owned();
let symbology = normalize_symbology(&form.symbology);
if store_name.is_empty() || store_name.chars().count() > 60 {
return Err(AppError::BadRequest(
"Store names must be between 1 and 60 characters.".into(),
));
}
if number.is_empty() || number.chars().count() > 80 {
return Err(AppError::BadRequest(
"Card numbers must be between 1 and 80 characters.".into(),
));
}
state
.rewards_cards
.create_card(user.session.user.id, store_name, number, symbology)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
async fn delete_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
Path(card_id): Path<i64>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
state
.rewards_cards
.delete_card(user.session.user.id, card_id)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
/// Normalizes a submitted symbology value to a known barcode type, falling
/// back to Code 128 for unknown or empty values.
fn normalize_symbology(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"code39" => "code39".to_owned(),
_ => "code128".to_owned(),
}
}
async fn meals_page(
State(state): State<AppState>,
user: CurrentUser,