05/08 Phase 4

This commit is contained in:
2026-05-08 10:15:40 -04:00
parent 959f54ed84
commit bf9b54f6b5
26 changed files with 1509 additions and 42 deletions
+184
View File
@@ -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)