05/08 Phase 4
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
app/scheduler_jobs.py
|
||||
APScheduler job definitions for the tenant portal.
|
||||
Registered in create_tenant_app() after scheduler.init_app().
|
||||
|
||||
Jobs:
|
||||
- send_appointment_reminders every 5 minutes
|
||||
- send_review_requests every 10 minutes
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_appointment_reminders(app):
|
||||
"""
|
||||
Scan appointment_reminders for pending reminders due to be sent.
|
||||
Sends email via Flask-Mail and marks status = 'sent' or 'failed'.
|
||||
Runs every 5 minutes.
|
||||
"""
|
||||
with app.app_context():
|
||||
from app.extensions import db, mail
|
||||
from app.models.salon import AppointmentReminder, Appointment, Customer
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
due = AppointmentReminder.query.filter_by(status="pending").filter(
|
||||
AppointmentReminder.scheduled_for <= now
|
||||
).limit(50).all()
|
||||
|
||||
if not due:
|
||||
return
|
||||
|
||||
sent = failed = 0
|
||||
for reminder in due:
|
||||
appt = Appointment.query.get(reminder.appointment_id)
|
||||
if not appt or appt.status in ("cancelled", "no_show"):
|
||||
reminder.status = "cancelled"
|
||||
continue
|
||||
|
||||
customer = Customer.query.get(appt.customer_id) if appt.customer_id else None
|
||||
if not customer or not customer.email:
|
||||
# No email — mark sent so we don't retry endlessly
|
||||
reminder.status = "sent"
|
||||
reminder.sent_at = now
|
||||
sent += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
from flask_mail import Message
|
||||
from app.models.platform import Tenant
|
||||
tenant = Tenant.query.get(appt.tenant_id)
|
||||
tenant_name = tenant.name if tenant else "Your salon"
|
||||
|
||||
msg = Message(
|
||||
subject=f"Appointment Reminder — {tenant_name}",
|
||||
recipients=[customer.email],
|
||||
body=(
|
||||
f"Hi {customer.name},\n\n"
|
||||
f"This is a reminder of your upcoming appointment:\n"
|
||||
f"Date: {appt.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||
f"Service: {appt.service.name if appt.service else 'N/A'}\n\n"
|
||||
f"See you soon!\n{tenant_name}"
|
||||
),
|
||||
)
|
||||
mail.send(msg)
|
||||
reminder.status = "sent"
|
||||
reminder.sent_at = now
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
logger.error("Reminder send failed id=%s: %s", reminder.id, exc)
|
||||
reminder.status = "failed"
|
||||
failed += 1
|
||||
|
||||
db.session.commit()
|
||||
if sent or failed:
|
||||
logger.info("Appointment reminders: sent=%d failed=%d", sent, failed)
|
||||
|
||||
|
||||
def send_review_requests(app):
|
||||
"""
|
||||
Find completed transactions where review_request_sent_at IS NULL and
|
||||
the transaction is old enough (per tenant setting review_request_delay_minutes).
|
||||
Sends review email with smart routing:
|
||||
- rating ≥ 4 → shows Google/Facebook/Yelp links
|
||||
- rating < 4 → silent internal feedback only
|
||||
One send per transaction enforced by review_request_sent_at.
|
||||
Runs every 10 minutes.
|
||||
"""
|
||||
with app.app_context():
|
||||
from app.extensions import db, mail
|
||||
from app.models.salon import Transaction, Customer, TenantSetting, CheckoutReview
|
||||
from app.models.platform import Tenant
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Find eligible transactions: completed, not voided, no review sent yet
|
||||
candidates = Transaction.query.filter(
|
||||
Transaction.voided_at.is_(None),
|
||||
Transaction.review_request_sent_at.is_(None),
|
||||
Transaction.customer_id.isnot(None),
|
||||
).limit(100).all()
|
||||
|
||||
sent = 0
|
||||
for txn in candidates:
|
||||
# Get tenant-specific delay (default 60 minutes)
|
||||
delay_setting = TenantSetting.query.filter_by(
|
||||
tenant_id=txn.tenant_id,
|
||||
setting_key="review_request_delay_minutes",
|
||||
).first()
|
||||
delay_minutes = int(delay_setting.setting_value or 60) if delay_setting else 60
|
||||
eligible_after = txn.created_at.replace(tzinfo=timezone.utc) + timedelta(minutes=delay_minutes)
|
||||
|
||||
if now < eligible_after:
|
||||
continue
|
||||
|
||||
customer = Customer.query.get(txn.customer_id)
|
||||
if not customer or not customer.email:
|
||||
# Mark sent to avoid retrying
|
||||
txn.review_request_sent_at = now
|
||||
continue
|
||||
|
||||
tenant = Tenant.query.get(txn.tenant_id)
|
||||
tenant_name = tenant.name if tenant else "Your salon"
|
||||
|
||||
# Fetch review settings
|
||||
def _get_setting(key):
|
||||
s = TenantSetting.query.filter_by(
|
||||
tenant_id=txn.tenant_id, setting_key=key).first()
|
||||
return s.setting_value if s else None
|
||||
|
||||
google_url = _get_setting("google_review_url")
|
||||
yelp_url = _get_setting("yelp_review_url")
|
||||
facebook_url = _get_setting("facebook_review_url")
|
||||
|
||||
# Check if a review already submitted
|
||||
existing_review = CheckoutReview.query.filter_by(
|
||||
transaction_id=txn.id
|
||||
).first()
|
||||
|
||||
try:
|
||||
from flask_mail import Message
|
||||
|
||||
if existing_review and existing_review.rating >= 4:
|
||||
# High rating — encourage public review
|
||||
links = []
|
||||
if google_url:
|
||||
links.append(f"Google: {google_url}")
|
||||
if yelp_url:
|
||||
links.append(f"Yelp: {yelp_url}")
|
||||
if facebook_url:
|
||||
links.append(f"Facebook: {facebook_url}")
|
||||
|
||||
body = (
|
||||
f"Hi {customer.name},\n\n"
|
||||
f"Thank you for visiting {tenant_name}! We're so glad you enjoyed your visit.\n"
|
||||
f"Would you mind sharing your experience online?\n\n"
|
||||
+ ("\n".join(links) if links else "(No review links configured)")
|
||||
+ f"\n\nThank you!\n{tenant_name}"
|
||||
)
|
||||
else:
|
||||
# No review yet or low rating — generic thank you only
|
||||
body = (
|
||||
f"Hi {customer.name},\n\n"
|
||||
f"Thank you for visiting {tenant_name}! We hope to see you again soon.\n\n"
|
||||
f"{tenant_name}"
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject=f"Thank you for visiting {tenant_name}!",
|
||||
recipients=[customer.email],
|
||||
body=body,
|
||||
)
|
||||
mail.send(msg)
|
||||
txn.review_request_sent_at = now
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
logger.error("Review request failed txn=%s: %s", txn.id, exc)
|
||||
# Don't mark sent_at — will retry next cycle
|
||||
|
||||
db.session.commit()
|
||||
if sent:
|
||||
logger.info("Review requests sent: %d", sent)
|
||||
Reference in New Issue
Block a user