Add chase notifications and weekly digest emails

This commit is contained in:
fullsizemalt 2025-12-26 22:50:09 -08:00
parent 2504b15f29
commit 4c4f2b437e
5 changed files with 350 additions and 0 deletions

View file

@ -265,6 +265,7 @@ def import_setlists(session, show_map, song_map):
page = 1
total_processed = 0
performance_count = 0
new_performance_ids = [] # Track new performances for chase notifications
params = {}
while True:
@ -321,12 +322,24 @@ def import_setlists(session, show_map, song_map):
notes=perf_data.get('notes')
)
session.add(perf)
session.flush() # Get the ID
new_performance_ids.append(perf.id)
count_in_page += 1
performance_count += 1
session.commit()
# Send chase notifications for new performances
if new_performance_ids:
try:
from services.chase_notifications import check_new_performances_for_chases
check_new_performances_for_chases(session, new_performance_ids)
except Exception as e:
print(f" Warning: Chase notifications failed: {e}")
print(f" Validated/Added {count_in_page} items.")
page += 1
new_performance_ids = [] # Reset for next page
print(f"✓ Imported {performance_count} new performances")

View file

@ -0,0 +1,6 @@
#!/bin/sh
# Cron job runner for Elmeg
# Add to crontab: 0 9 * * 0 /app/scripts/cron_weekly_digest.sh
cd /app
python services/weekly_digest.py

View file

@ -0,0 +1,87 @@
"""
Chase Notification Service
Sends email notifications to users when songs they're chasing are played.
"""
from sqlmodel import Session, select
from models import ChaseSong, User, Song, Show, Performance, Venue
from helpers import create_notification
def notify_chase_users(session: Session, song_id: int, show_id: int, performance_id: int):
"""
Send notifications to all users chasing this song.
Called after a new performance is imported.
"""
from services.email_service import send_chase_notification_email
# Get song, show, and venue info for the notification
song = session.get(Song, song_id)
show = session.get(Show, show_id)
if not song or not show:
return 0
venue = session.get(Venue, show.venue_id) if show.venue_id else None
# Find all users chasing this song who haven't caught it yet
chase_songs = session.exec(
select(ChaseSong)
.where(ChaseSong.song_id == song_id)
.where(ChaseSong.caught_at.is_(None)) # Not yet caught
).all()
notified_count = 0
for chase in chase_songs:
user = session.get(User, chase.user_id)
if not user:
continue
# Create in-app notification
create_notification(
session,
user_id=user.id,
title="Chase Alert!",
message=f"{song.title} was played at {venue.name if venue else 'a show'}!",
type="chase",
link=f"/performances/{performance_id}" # Link to performance page
)
# Send email if user has chase alerts enabled
if user.preferences and user.preferences.email_on_chase:
show_date = show.date.strftime("%Y-%m-%d") if show.date else "Unknown date"
venue_name = venue.name if venue else "Unknown venue"
# Get performance slug for link
perf = session.get(Performance, performance_id)
perf_link = f"/performances/{perf.slug}" if perf and perf.slug else f"/shows/{show.slug}"
send_chase_notification_email(
to_email=user.email,
song_title=song.title,
show_date=show_date,
venue_name=venue_name,
link=perf_link
)
notified_count += 1
return notified_count
def check_new_performances_for_chases(session: Session, performance_ids: list):
"""
Check a list of newly imported performances and notify chase users.
Call this after batch importing shows.
"""
total_notified = 0
for perf_id in performance_ids:
perf = session.get(Performance, perf_id)
if perf:
count = notify_chase_users(session, perf.song_id, perf.show_id, perf.id)
total_notified += count
if total_notified > 0:
print(f"📧 Sent {total_notified} chase notification emails")
return total_notified

View file

@ -0,0 +1,220 @@
"""
Weekly Digest Email Service
Sends weekly digest emails to users who have email_digest enabled.
Run via cron job: 0 9 * * 0 (Sunday at 9am)
"""
import os
import sys
# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from datetime import datetime, timedelta
from sqlmodel import Session, select, func
from database import engine
from models import User, Show, Performance, Review, Rating, ChaseSong, Attendance
def get_weekly_stats(session: Session, user_id: int, start_date: datetime, end_date: datetime):
"""Get user's activity stats for the week"""
# Shows attended this week
shows_attended = session.exec(
select(func.count(Attendance.id))
.where(Attendance.user_id == user_id)
.where(Attendance.created_at >= start_date)
.where(Attendance.created_at < end_date)
).one() or 0
# Reviews written this week
reviews_written = session.exec(
select(func.count(Review.id))
.where(Review.user_id == user_id)
.where(Review.created_at >= start_date)
.where(Review.created_at < end_date)
).one() or 0
# Ratings given this week
ratings_given = session.exec(
select(func.count(Rating.id))
.where(Rating.user_id == user_id)
.where(Rating.created_at >= start_date)
.where(Rating.created_at < end_date)
).one() or 0
# Check for caught chase songs
chase_caught = session.exec(
select(func.count(ChaseSong.id))
.where(ChaseSong.user_id == user_id)
.where(ChaseSong.caught_at >= start_date)
.where(ChaseSong.caught_at < end_date)
).one() or 0
return {
"shows_attended": shows_attended,
"reviews_written": reviews_written,
"ratings_given": ratings_given,
"chase_caught": chase_caught
}
def get_community_highlights(session: Session, start_date: datetime, end_date: datetime):
"""Get community highlights for the week"""
# New shows added
new_shows = session.exec(
select(func.count(Show.id))
.where(Show.date >= start_date.date())
.where(Show.date < end_date.date())
).one() or 0
# Total reviews this week
total_reviews = session.exec(
select(func.count(Review.id))
.where(Review.created_at >= start_date)
.where(Review.created_at < end_date)
).one() or 0
# Most reviewed performance this week
top_perf = session.exec(
select(Performance.id, func.count(Review.id).label("count"))
.join(Review, Review.performance_id == Performance.id)
.where(Review.created_at >= start_date)
.where(Review.created_at < end_date)
.group_by(Performance.id)
.order_by(func.count(Review.id).desc())
.limit(1)
).first()
return {
"new_shows": new_shows,
"total_reviews": total_reviews,
"top_performance_id": top_perf[0] if top_perf else None
}
def send_digest_email(user, stats: dict, highlights: dict, frontend_url: str):
"""Send the weekly digest email to a user"""
from services.email_service import email_service
subject = "Your Weekly Elmeg Digest"
# Build activity section
activity_items = []
if stats["shows_attended"] > 0:
activity_items.append(f"🎵 Attended {stats['shows_attended']} show{'s' if stats['shows_attended'] > 1 else ''}")
if stats["reviews_written"] > 0:
activity_items.append(f"✍️ Wrote {stats['reviews_written']} review{'s' if stats['reviews_written'] > 1 else ''}")
if stats["ratings_given"] > 0:
activity_items.append(f"⭐ Gave {stats['ratings_given']} rating{'s' if stats['ratings_given'] > 1 else ''}")
if stats["chase_caught"] > 0:
activity_items.append(f"🎯 Caught {stats['chase_caught']} chase song{'s' if stats['chase_caught'] > 1 else ''}!")
activity_html = ""
if activity_items:
activity_html = "<ul>" + "".join([f"<li>{item}</li>" for item in activity_items]) + "</ul>"
else:
activity_html = "<p style='color: #666;'>No activity this week. Get out there and catch some shows!</p>"
html_content = f"""
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #2563eb; margin-top: 0;">Weekly Digest</h2>
<h3 style="color: #374151; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px;">Your Activity</h3>
{activity_html}
<h3 style="color: #374151; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; margin-top: 30px;">Community Highlights</h3>
<ul>
<li>🆕 {highlights['new_shows']} new show{'s' if highlights['new_shows'] != 1 else ''} added</li>
<li>📝 {highlights['total_reviews']} review{'s' if highlights['total_reviews'] != 1 else ''} written by the community</li>
</ul>
<div style="margin: 30px 0; text-align: center;">
<a href="{frontend_url}" style="background: linear-gradient(135deg, #2563eb, #4f46e5); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: 600; display: inline-block;">Visit Elmeg</a>
</div>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #999; margin-bottom: 0;">You're receiving this because you enabled weekly digests. <a href="{frontend_url}/settings" style="color: #2563eb;">Manage preferences</a></p>
</div>
</body>
</html>
"""
text_content = f"""
Weekly Elmeg Digest
YOUR ACTIVITY:
- Attended {stats['shows_attended']} shows
- Wrote {stats['reviews_written']} reviews
- Gave {stats['ratings_given']} ratings
- Caught {stats['chase_caught']} chase songs
COMMUNITY HIGHLIGHTS:
- {highlights['new_shows']} new shows added
- {highlights['total_reviews']} reviews written
Visit Elmeg: {frontend_url}
To disable weekly digests, visit {frontend_url}/settings
"""
return email_service.send_email(user.email, subject, html_content, text_content)
def send_weekly_digests():
"""Main function to send all weekly digest emails"""
print("=" * 60)
print("WEEKLY DIGEST EMAIL SENDER")
print("=" * 60)
# Calculate date range (last 7 days)
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=7)
print(f"Period: {start_date.date()} to {end_date.date()}")
from services.email_service import email_service
frontend_url = email_service.frontend_url
with Session(engine) as session:
# Get community highlights (shared across all emails)
highlights = get_community_highlights(session, start_date, end_date)
print(f"Community stats: {highlights['new_shows']} shows, {highlights['total_reviews']} reviews")
# Find all users with email_digest enabled
users_with_digest = session.exec(
select(User)
.where(User.is_active == True)
.where(User.email_verified == True)
).all()
sent_count = 0
skipped_count = 0
for user in users_with_digest:
# Check if user has digest enabled
if not user.preferences or not user.preferences.email_digest:
skipped_count += 1
continue
# Get user's stats
stats = get_weekly_stats(session, user.id, start_date, end_date)
# Send the digest
try:
success = send_digest_email(user, stats, highlights, frontend_url)
if success:
sent_count += 1
print(f" ✓ Sent to {user.email}")
else:
print(f" ✗ Failed for {user.email}")
except Exception as e:
print(f" ✗ Error for {user.email}: {e}")
print(f"\n✓ Sent {sent_count} digest emails, skipped {skipped_count}")
if __name__ == "__main__":
send_weekly_digests()

View file

@ -130,6 +130,30 @@ services:
networks:
- elmeg
# Weekly digest email cron job (runs Sunday 9am UTC)
cron:
build: ./backend
volumes:
- ./backend:/app
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql://elmeg:elmeg_password@db:5432/elmeg}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-25}
- SMTP_USERNAME=${SMTP_USERNAME}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_USE_TLS=${SMTP_USE_TLS:-true}
- EMAIL_FROM=${EMAIL_FROM:-noreply@elmeg.xyz}
- FRONTEND_URL=${FRONTEND_URL:-https://elmeg.xyz}
command: >
sh -c "echo '0 9 * * 0 cd /app && python services/weekly_digest.py >> /var/log/cron.log 2>&1' | crontab - && crond -f -l 2"
depends_on:
- db
- backend
restart: unless-stopped
networks:
- elmeg
- postal-internal
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped