Add recurring scheduled messages (daily, weekly, monthly)
Extend scheduled messages with a recurrence field. After sending a recurring message, the scheduler automatically creates the next pending occurrence. Frontend adds repeat toggle (Once/Daily/Weekly/Monthly) and displays recurrence in the messages table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
|||||||
from quart import Blueprint, request, jsonify
|
from quart import Blueprint, request, jsonify
|
||||||
|
|
||||||
from blueprints.users.decorators import admin_required
|
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_blueprint = Blueprint(
|
||||||
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
||||||
@@ -22,6 +22,7 @@ def _serialize(msg: ScheduledMessage) -> dict:
|
|||||||
"subject": msg.subject,
|
"subject": msg.subject,
|
||||||
"scheduled_at": msg.scheduled_at.isoformat(),
|
"scheduled_at": msg.scheduled_at.isoformat(),
|
||||||
"status": msg.status.value,
|
"status": msg.status.value,
|
||||||
|
"recurrence": msg.recurrence.value,
|
||||||
"error_message": msg.error_message,
|
"error_message": msg.error_message,
|
||||||
"created_at": msg.created_at.isoformat(),
|
"created_at": msg.created_at.isoformat(),
|
||||||
"updated_at": msg.updated_at.isoformat(),
|
"updated_at": msg.updated_at.isoformat(),
|
||||||
@@ -48,6 +49,8 @@ async def create_message():
|
|||||||
subject = (data.get("subject") or "").strip() or None
|
subject = (data.get("subject") or "").strip() or None
|
||||||
scheduled_at_str = data.get("scheduled_at")
|
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:
|
if not recipient or not channel or not content or not scheduled_at_str:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{"error": "recipient, channel, content, and scheduled_at are required"}
|
{"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'"}
|
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
|
||||||
), 400
|
), 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:
|
if channel_enum == MessageChannel.EMAIL and not subject:
|
||||||
return jsonify({"error": "subject is required for email messages"}), 400
|
return jsonify({"error": "subject is required for email messages"}), 400
|
||||||
|
|
||||||
@@ -83,6 +95,7 @@ async def create_message():
|
|||||||
content=content,
|
content=content,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
scheduled_at=scheduled_at,
|
scheduled_at=scheduled_at,
|
||||||
|
recurrence=recurrence_enum,
|
||||||
created_by_id=user_id,
|
created_by_id=user_id,
|
||||||
)
|
)
|
||||||
return jsonify(_serialize(msg)), 201
|
return jsonify(_serialize(msg)), 201
|
||||||
@@ -113,6 +126,11 @@ async def update_message(msg_id: str):
|
|||||||
msg.content = data["content"].strip()
|
msg.content = data["content"].strip()
|
||||||
if "subject" in data:
|
if "subject" in data:
|
||||||
msg.subject = data["subject"].strip() or None
|
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:
|
if "scheduled_at" in data:
|
||||||
try:
|
try:
|
||||||
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ class MessageStatus(enum.Enum):
|
|||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class Recurrence(enum.Enum):
|
||||||
|
NONE = "none"
|
||||||
|
DAILY = "daily"
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
|
||||||
|
|
||||||
class ScheduledMessage(Model):
|
class ScheduledMessage(Model):
|
||||||
id = fields.UUIDField(primary_key=True)
|
id = fields.UUIDField(primary_key=True)
|
||||||
recipient = fields.CharField(max_length=255)
|
recipient = fields.CharField(max_length=255)
|
||||||
@@ -26,6 +33,9 @@ class ScheduledMessage(Model):
|
|||||||
status = fields.CharEnumField(
|
status = fields.CharEnumField(
|
||||||
enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING
|
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)
|
error_message = fields.TextField(null=True)
|
||||||
created_by = fields.ForeignKeyField(
|
created_by = fields.ForeignKeyField(
|
||||||
"models.User", related_name="scheduled_messages"
|
"models.User", related_name="scheduled_messages"
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
POLL_INTERVAL = 15
|
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():
|
async def scheduled_messages_loop():
|
||||||
"""Background loop that polls for and sends due scheduled messages."""
|
"""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}"
|
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:
|
except Exception as e:
|
||||||
msg.status = MessageStatus.FAILED
|
msg.status = MessageStatus.FAILED
|
||||||
msg.error_message = str(e)
|
msg.error_message = str(e)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface ScheduledMessage {
|
|||||||
subject: string | null;
|
subject: string | null;
|
||||||
scheduled_at: string;
|
scheduled_at: string;
|
||||||
status: "pending" | "sent" | "failed" | "cancelled";
|
status: "pending" | "sent" | "failed" | "cancelled";
|
||||||
|
recurrence: "none" | "daily" | "weekly" | "monthly";
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -19,6 +20,7 @@ export interface CreateScheduledMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
scheduled_at: string;
|
scheduled_at: string;
|
||||||
|
recurrence?: "none" | "daily" | "weekly" | "monthly";
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScheduledMessageService {
|
class ScheduledMessageService {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { cn } from "../lib/utils";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
@@ -36,6 +36,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [scheduledAt, setScheduledAt] = useState("");
|
const [scheduledAt, setScheduledAt] = useState("");
|
||||||
|
const [recurrence, setRecurrence] = useState<"none" | "daily" | "weekly" | "monthly">("none");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
channel,
|
channel,
|
||||||
content,
|
content,
|
||||||
scheduled_at: new Date(scheduledAt).toISOString(),
|
scheduled_at: new Date(scheduledAt).toISOString(),
|
||||||
|
recurrence,
|
||||||
};
|
};
|
||||||
if (channel === "email") data.subject = subject;
|
if (channel === "email") data.subject = subject;
|
||||||
await scheduledMessageService.create(data);
|
await scheduledMessageService.create(data);
|
||||||
@@ -64,6 +66,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
setSubject("");
|
setSubject("");
|
||||||
setContent("");
|
setContent("");
|
||||||
setScheduledAt("");
|
setScheduledAt("");
|
||||||
|
setRecurrence("none");
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to schedule message");
|
setError(err instanceof Error ? err.message : "Failed to schedule message");
|
||||||
@@ -167,6 +170,25 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Repeat size={12} className="text-warm-gray" />
|
||||||
|
<span className="text-xs text-warm-gray">Repeat:</span>
|
||||||
|
{(["none", "daily", "weekly", "monthly"] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRecurrence(r)}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
recurrence === r
|
||||||
|
? "bg-amber-pale text-amber-glow"
|
||||||
|
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r === "none" ? "Once" : r.charAt(0).toUpperCase() + r.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{channel === "email" && (
|
{channel === "email" && (
|
||||||
<Input
|
<Input
|
||||||
value={subject}
|
value={subject}
|
||||||
@@ -213,6 +235,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
<TableHead>Recipient</TableHead>
|
<TableHead>Recipient</TableHead>
|
||||||
<TableHead>Content</TableHead>
|
<TableHead>Content</TableHead>
|
||||||
<TableHead>Scheduled</TableHead>
|
<TableHead>Scheduled</TableHead>
|
||||||
|
<TableHead>Repeat</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-28">Actions</TableHead>
|
<TableHead className="w-28">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -234,6 +257,9 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
|||||||
<TableCell className="text-xs text-warm-gray">
|
<TableCell className="text-xs text-warm-gray">
|
||||||
{new Date(msg.scheduled_at).toLocaleString()}
|
{new Date(msg.scheduled_at).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-warm-gray capitalize">
|
||||||
|
{msg.recurrence === "none" ? "—" : msg.recurrence}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
Reference in New Issue
Block a user