diff --git a/blueprints/scheduled_messages/__init__.py b/blueprints/scheduled_messages/__init__.py index f8b9b99..96f0bfd 100644 --- a/blueprints/scheduled_messages/__init__.py +++ b/blueprints/scheduled_messages/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from quart import Blueprint, request, jsonify from blueprints.users.decorators import admin_required -from .models import ScheduledMessage, MessageChannel, MessageStatus +from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence scheduled_messages_blueprint = Blueprint( "scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages" @@ -22,6 +22,7 @@ def _serialize(msg: ScheduledMessage) -> dict: "subject": msg.subject, "scheduled_at": msg.scheduled_at.isoformat(), "status": msg.status.value, + "recurrence": msg.recurrence.value, "error_message": msg.error_message, "created_at": msg.created_at.isoformat(), "updated_at": msg.updated_at.isoformat(), @@ -48,6 +49,8 @@ async def create_message(): subject = (data.get("subject") or "").strip() or None scheduled_at_str = data.get("scheduled_at") + recurrence_str = data.get("recurrence", "none") + if not recipient or not channel or not content or not scheduled_at_str: return jsonify( {"error": "recipient, channel, content, and scheduled_at are required"} @@ -60,6 +63,15 @@ async def create_message(): {"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"} ), 400 + try: + recurrence_enum = Recurrence(recurrence_str) + except ValueError: + return jsonify( + { + "error": f"Invalid recurrence: {recurrence_str}. Must be 'none', 'daily', 'weekly', or 'monthly'" + } + ), 400 + if channel_enum == MessageChannel.EMAIL and not subject: return jsonify({"error": "subject is required for email messages"}), 400 @@ -83,6 +95,7 @@ async def create_message(): content=content, subject=subject, scheduled_at=scheduled_at, + recurrence=recurrence_enum, created_by_id=user_id, ) return jsonify(_serialize(msg)), 201 @@ -113,6 +126,11 @@ async def update_message(msg_id: str): msg.content = data["content"].strip() if "subject" in data: msg.subject = data["subject"].strip() or None + if "recurrence" in data: + try: + msg.recurrence = Recurrence(data["recurrence"]) + except ValueError: + return jsonify({"error": f"Invalid recurrence: {data['recurrence']}"}), 400 if "scheduled_at" in data: try: scheduled_at = datetime.fromisoformat(data["scheduled_at"]) diff --git a/blueprints/scheduled_messages/models.py b/blueprints/scheduled_messages/models.py index 11d3218..dedad96 100644 --- a/blueprints/scheduled_messages/models.py +++ b/blueprints/scheduled_messages/models.py @@ -16,6 +16,13 @@ class MessageStatus(enum.Enum): CANCELLED = "cancelled" +class Recurrence(enum.Enum): + NONE = "none" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + class ScheduledMessage(Model): id = fields.UUIDField(primary_key=True) recipient = fields.CharField(max_length=255) @@ -26,6 +33,9 @@ class ScheduledMessage(Model): status = fields.CharEnumField( enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING ) + recurrence = fields.CharEnumField( + enum_type=Recurrence, max_length=10, default=Recurrence.NONE + ) error_message = fields.TextField(null=True) created_by = fields.ForeignKeyField( "models.User", related_name="scheduled_messages" diff --git a/blueprints/scheduled_messages/scheduler.py b/blueprints/scheduled_messages/scheduler.py index 9ce428c..54c177b 100644 --- a/blueprints/scheduled_messages/scheduler.py +++ b/blueprints/scheduled_messages/scheduler.py @@ -1,13 +1,46 @@ import asyncio import logging from datetime import datetime, timezone +from dateutil.relativedelta import relativedelta -from .models import ScheduledMessage, MessageChannel, MessageStatus +from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence logger = logging.getLogger(__name__) POLL_INTERVAL = 15 +RECURRENCE_DELTAS = { + Recurrence.DAILY: relativedelta(days=1), + Recurrence.WEEKLY: relativedelta(weeks=1), + Recurrence.MONTHLY: relativedelta(months=1), +} + + +async def _schedule_next_occurrence(msg: ScheduledMessage): + """Create the next pending occurrence for a recurring message.""" + delta = RECURRENCE_DELTAS.get(msg.recurrence) + if not delta: + return + + next_at = msg.scheduled_at + delta + # If we missed several intervals, advance until we're in the future + now = datetime.now(timezone.utc) + while next_at <= now: + next_at += delta + + await ScheduledMessage.create( + recipient=msg.recipient, + channel=msg.channel, + content=msg.content, + subject=msg.subject, + scheduled_at=next_at, + recurrence=msg.recurrence, + created_by_id=msg.created_by_id, + ) + logger.info( + f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}" + ) + async def scheduled_messages_loop(): """Background loop that polls for and sends due scheduled messages.""" @@ -45,6 +78,10 @@ async def scheduled_messages_loop(): f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}" ) + # Schedule next occurrence for recurring messages + if msg.recurrence != Recurrence.NONE: + await _schedule_next_occurrence(msg) + except Exception as e: msg.status = MessageStatus.FAILED msg.error_message = str(e) diff --git a/raggr-frontend/src/api/scheduledMessageService.ts b/raggr-frontend/src/api/scheduledMessageService.ts index b6ff803..45173da 100644 --- a/raggr-frontend/src/api/scheduledMessageService.ts +++ b/raggr-frontend/src/api/scheduledMessageService.ts @@ -8,6 +8,7 @@ export interface ScheduledMessage { subject: string | null; scheduled_at: string; status: "pending" | "sent" | "failed" | "cancelled"; + recurrence: "none" | "daily" | "weekly" | "monthly"; error_message: string | null; created_at: string; updated_at: string; @@ -19,6 +20,7 @@ export interface CreateScheduledMessage { content: string; subject?: string; scheduled_at: string; + recurrence?: "none" | "daily" | "weekly" | "monthly"; } class ScheduledMessageService { diff --git a/raggr-frontend/src/components/ScheduledMessagesPanel.tsx b/raggr-frontend/src/components/ScheduledMessagesPanel.tsx index 5a7855e..3fad381 100644 --- a/raggr-frontend/src/components/ScheduledMessagesPanel.tsx +++ b/raggr-frontend/src/components/ScheduledMessagesPanel.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { X, Clock, Send, Trash2, XCircle, RotateCcw } from "lucide-react"; +import { X, Clock, Send, Trash2, XCircle, RotateCcw, Repeat } from "lucide-react"; import { cn } from "../lib/utils"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -36,6 +36,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => { const [subject, setSubject] = useState(""); const [content, setContent] = useState(""); const [scheduledAt, setScheduledAt] = useState(""); + const [recurrence, setRecurrence] = useState<"none" | "daily" | "weekly" | "monthly">("none"); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -57,6 +58,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => { channel, content, scheduled_at: new Date(scheduledAt).toISOString(), + recurrence, }; if (channel === "email") data.subject = subject; await scheduledMessageService.create(data); @@ -64,6 +66,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => { setSubject(""); setContent(""); setScheduledAt(""); + setRecurrence("none"); refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to schedule message"); @@ -167,6 +170,25 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => { /> +
+ + Repeat: + {(["none", "daily", "weekly", "monthly"] as const).map((r) => ( + + ))} +
+ {channel === "email" && ( { Recipient Content Scheduled + Repeat Status Actions @@ -234,6 +257,9 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => { {new Date(msg.scheduled_at).toLocaleString()} + + {msg.recurrence === "none" ? "—" : msg.recurrence} + {msg.status}