feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
Some checks failed
Add async email notifications for 5 loyalty lifecycle events, using the existing messaging module infrastructure (EmailService, EmailLog, store template overrides). - New seed script: scripts/seed/seed_email_templates_loyalty.py Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent. Renamed existing script to seed_email_templates_core.py. - Celery task: loyalty.send_notification_email — async dispatch with 3 retries and 60s backoff. Opens own DB session. - Notification service: LoyaltyNotificationService with 5 methods that resolve customer/card/program into template variables and enqueue via Celery (never blocks request handlers). - Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if bonus > 0) after card creation commit. - Stamps: sends loyalty_reward_ready when stamp target reached. - Expiration task: sends loyalty_points_expiring 14 days before expiry (tracked via new last_expiration_warning_at column to prevent dupes), and loyalty_points_expired after points are zeroed. - Migration loyalty_005: adds last_expiration_warning_at to cards. - 8 new unit tests for notification service dispatch. - Fix: rate limiter autouse fixture in integration tests to prevent state bleed between tests. Templates: loyalty_enrollment, loyalty_welcome_bonus, loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready. All support store-level overrides via the existing email template UI. Birthday + re-engagement emails deferred to future marketing module (cross-platform: OMS, loyalty, hosting). 342 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
137
app/modules/loyalty/services/notification_service.py
Normal file
137
app/modules/loyalty/services/notification_service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# app/modules/loyalty/services/notification_service.py
|
||||
"""
|
||||
Loyalty notification service.
|
||||
|
||||
Thin wrapper that resolves customer/card/program data into template
|
||||
variables and dispatches emails asynchronously via the Celery task.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoyaltyNotificationService:
|
||||
"""Dispatches loyalty email notifications."""
|
||||
|
||||
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
|
||||
"""Load customer, store, and program info for a card.
|
||||
|
||||
Returns None if the customer has no email (can't send).
|
||||
"""
|
||||
from app.modules.customers.services.customer_service import customer_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
customer = customer_service.get_customer_by_id(db, card.customer_id)
|
||||
if not customer or not customer.email:
|
||||
return None
|
||||
|
||||
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
|
||||
program = card.program
|
||||
|
||||
return {
|
||||
"customer": customer,
|
||||
"store": store,
|
||||
"program": program,
|
||||
"to_email": customer.email,
|
||||
"to_name": customer.full_name,
|
||||
"store_id": card.enrolled_at_store_id,
|
||||
"customer_id": customer.id,
|
||||
"customer_name": customer.full_name,
|
||||
"program_name": program.display_name if program else "Loyalty Program",
|
||||
"store_name": store.name if store else "",
|
||||
}
|
||||
|
||||
def _dispatch(
|
||||
self, template_code: str, ctx: dict, extra_vars: dict | None = None
|
||||
):
|
||||
"""Enqueue a notification email via Celery."""
|
||||
from app.modules.loyalty.tasks.notifications import send_notification_email
|
||||
|
||||
variables = {
|
||||
"customer_name": ctx["customer_name"],
|
||||
"program_name": ctx["program_name"],
|
||||
"store_name": ctx["store_name"],
|
||||
}
|
||||
if extra_vars:
|
||||
variables.update(extra_vars)
|
||||
|
||||
send_notification_email.delay(
|
||||
template_code=template_code,
|
||||
to_email=ctx["to_email"],
|
||||
to_name=ctx["to_name"],
|
||||
variables=variables,
|
||||
store_id=ctx["store_id"],
|
||||
customer_id=ctx["customer_id"],
|
||||
)
|
||||
logger.info(
|
||||
f"Queued {template_code} for {ctx['to_email']}"
|
||||
)
|
||||
|
||||
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
|
||||
"""Send enrollment confirmation email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_enrollment", ctx, {
|
||||
"card_number": card.card_number,
|
||||
})
|
||||
|
||||
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
|
||||
"""Send welcome bonus notification (only if points > 0)."""
|
||||
if points <= 0:
|
||||
return
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_welcome_bonus", ctx, {
|
||||
"points": str(points),
|
||||
})
|
||||
|
||||
def send_points_expiration_warning(
|
||||
self,
|
||||
db: Session,
|
||||
card: LoyaltyCard,
|
||||
expiring_points: int,
|
||||
days_remaining: int,
|
||||
expiration_date: str,
|
||||
):
|
||||
"""Send points expiring warning email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_points_expiring", ctx, {
|
||||
"points": str(expiring_points),
|
||||
"days_remaining": str(days_remaining),
|
||||
"expiration_date": expiration_date,
|
||||
})
|
||||
|
||||
def send_points_expired(
|
||||
self, db: Session, card: LoyaltyCard, expired_points: int
|
||||
):
|
||||
"""Send points expired notification email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_points_expired", ctx, {
|
||||
"expired_points": str(expired_points),
|
||||
})
|
||||
|
||||
def send_reward_available(
|
||||
self, db: Session, card: LoyaltyCard, reward_name: str
|
||||
):
|
||||
"""Send reward earned notification email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_reward_ready", ctx, {
|
||||
"reward_name": reward_name,
|
||||
})
|
||||
|
||||
|
||||
# Singleton
|
||||
notification_service = LoyaltyNotificationService()
|
||||
Reference in New Issue
Block a user