fix: add GET /cards/lookup endpoint for loyalty terminal customer search
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 47m33s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 13:55:11 +01:00
parent cfce6c0ca4
commit 3de69e55a1
2 changed files with 112 additions and 40 deletions

View File

@@ -311,6 +311,73 @@ def list_cards(
return CardListResponse(cards=card_responses, total=total) 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) @store_router.post("/cards/lookup", response_model=CardLookupResponse)
def lookup_card( def lookup_card(
request: Request, request: Request,
@@ -337,46 +404,7 @@ def lookup_card(
card_number=card_number, card_number=card_number,
) )
program = card.program return _build_card_lookup_response(card, db)
# 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,
)
@store_router.post("/cards/enroll", response_model=CardResponse, status_code=201) @store_router.post("/cards/enroll", response_model=CardResponse, status_code=201)

View File

@@ -255,6 +255,50 @@ class CardService:
merchant_id=store.merchant_id, 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( def list_cards(
self, self,
db: Session, db: Session,