From 3de69e55a15c3632b05b3d9c6a7dd24e4a225c54 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 24 Feb 2026 13:55:11 +0100 Subject: [PATCH] fix: add GET /cards/lookup endpoint for loyalty terminal customer search The terminal JS uses GET with a free-text ?q= parameter, but only a POST endpoint existed with typed params (card_id, qr_code, card_number). - Add search_card_for_store service method (tries card number then email) - Add GET /cards/lookup route using the service method - Extract _build_card_lookup_response helper to DRY up POST and GET endpoints Co-Authored-By: Claude Opus 4.6 --- app/modules/loyalty/routes/api/store.py | 108 ++++++++++++------- app/modules/loyalty/services/card_service.py | 44 ++++++++ 2 files changed, 112 insertions(+), 40 deletions(-) diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 0098ff50..f3cfedf2 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -311,6 +311,73 @@ def list_cards( return CardListResponse(cards=card_responses, total=total) +def _build_card_lookup_response(card, db=None) -> CardLookupResponse: + """Build a CardLookupResponse from a card object.""" + from datetime import timedelta + + program = card.program + + can_stamp, _ = card.can_stamp(program.cooldown_minutes) + cooldown_ends = None + if not can_stamp and card.last_stamp_at: + cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes) + + stamps_today = card_service.get_stamps_today(db, card.id) if db else 0 + + available_rewards = [] + for reward in program.points_rewards or []: + if reward.get("is_active", True) and card.points_balance >= reward.get( + "points_required", 0 + ): + available_rewards.append(reward) + + return CardLookupResponse( + card_id=card.id, + card_number=card.card_number, + customer_id=card.customer_id, + customer_name=card.customer.full_name if card.customer else None, + customer_email=card.customer.email if card.customer else "", + merchant_id=card.merchant_id, + merchant_name=card.merchant.name if card.merchant else None, + stamp_count=card.stamp_count, + stamps_target=program.stamps_target, + stamps_until_reward=max(0, program.stamps_target - card.stamp_count), + points_balance=card.points_balance, + can_redeem_stamps=card.stamp_count >= program.stamps_target, + stamp_reward_description=program.stamps_reward_description, + available_rewards=available_rewards, + can_stamp=can_stamp, + cooldown_ends_at=cooldown_ends, + stamps_today=stamps_today, + max_daily_stamps=program.max_daily_stamps, + can_earn_more_stamps=stamps_today < program.max_daily_stamps, + ) + + +@store_router.get("/cards/lookup", response_model=CardLookupResponse) +def search_card( + request: Request, + q: str = Query(..., description="Search by email, card number, or name"), + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Search for a card by email, card number, or customer name. + + Tries matching in order: card number, then customer email. + Card must belong to the same merchant as the store. + """ + store_id = current_user.token_store_id + + card = card_service.search_card_for_store(db, store_id, q) + if not card: + from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException + + raise LoyaltyCardNotFoundException(q) + + return _build_card_lookup_response(card, db) + + @store_router.post("/cards/lookup", response_model=CardLookupResponse) def lookup_card( request: Request, @@ -337,46 +404,7 @@ def lookup_card( card_number=card_number, ) - program = card.program - - # Check cooldown - can_stamp, _ = card.can_stamp(program.cooldown_minutes) - cooldown_ends = None - if not can_stamp and card.last_stamp_at: - from datetime import timedelta - - cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes) - - # Get stamps today - stamps_today = card_service.get_stamps_today(db, card.id) - - # Get available points rewards - available_rewards = [] - for reward in program.points_rewards or []: - if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0): - available_rewards.append(reward) - - return CardLookupResponse( - card_id=card.id, - card_number=card.card_number, - customer_id=card.customer_id, - customer_name=card.customer.full_name if card.customer else None, - customer_email=card.customer.email if card.customer else "", - merchant_id=card.merchant_id, - merchant_name=card.merchant.name if card.merchant else None, - stamp_count=card.stamp_count, - stamps_target=program.stamps_target, - stamps_until_reward=max(0, program.stamps_target - card.stamp_count), - points_balance=card.points_balance, - can_redeem_stamps=card.stamp_count >= program.stamps_target, - stamp_reward_description=program.stamps_reward_description, - available_rewards=available_rewards, - can_stamp=can_stamp, - cooldown_ends_at=cooldown_ends, - stamps_today=stamps_today, - max_daily_stamps=program.max_daily_stamps, - can_earn_more_stamps=stamps_today < program.max_daily_stamps, - ) + return _build_card_lookup_response(card, db) @store_router.post("/cards/enroll", response_model=CardResponse, status_code=201) diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index f823a8dc..30c2b8d3 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -255,6 +255,50 @@ class CardService: merchant_id=store.merchant_id, ) + def search_card_for_store( + self, + db: Session, + store_id: int, + query: str, + ) -> LoyaltyCard | None: + """ + Search for a card by free-text query (card number or customer email). + + Args: + db: Database session + store_id: Store ID (to scope merchant and customer lookup) + query: Search string — card number or customer email + + Returns: + Found card or None + """ + from app.modules.customers.models import Customer + from app.modules.tenancy.models import Store + + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + return None + + merchant_id = store.merchant_id + + # Try card number + card = self.get_card_by_number(db, query) + if card and card.merchant_id == merchant_id: + return card + + # Try customer email + customer = ( + db.query(Customer) + .filter(Customer.email == query, Customer.store_id == store_id) + .first() + ) + if customer: + card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id) + if card: + return card + + return None + def list_cards( self, db: Session,