fix(schedulers): wire recurring-task spawn + deliver event reminders
Drift-audit Group 2 (Phase-8 amputation — live wiring, no consumer): - Recurring tasks never recurred: spawn_recurring_tasks() had no caller. Register it as a 15-min interval job in the event scheduler (which app.py already starts/stops). Also add a deleted_at IS NULL guard to the spawn query in the same change, so a trashed recurring parent can never resurrect children once the sweep is live. - Event reminders were stamped reminder_sent_at but never delivered. _fire_reminders now creates an 'event_reminder' in-app notification before stamping, so a delivery failure stays retryable. Frontend NotificationsPanel renders the new type (⏰ + message); message logic pulled into a notifMessage() helper. - Remove the dead _fire_push_notif no-op stub (push left in Phase 8) and its three create_task call sites — no more throwaway tasks per share. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,23 @@ const typeIcon: Record<string, string> = {
|
||||
project_shared: '📁',
|
||||
note_shared: '📝',
|
||||
group_added: '👥',
|
||||
event_reminder: '⏰',
|
||||
}
|
||||
|
||||
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
|
||||
const p = n.payload
|
||||
switch (n.type) {
|
||||
case 'project_shared':
|
||||
return ` shared "${p.project_title}" with you as ${p.permission}`
|
||||
case 'note_shared':
|
||||
return ` shared "${p.note_title}" with you as ${p.permission}`
|
||||
case 'group_added':
|
||||
return ` added you to "${p.group_name}" as ${p.role}`
|
||||
case 'event_reminder':
|
||||
return `Reminder: "${p.title}" is coming up`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
|
||||
@@ -49,11 +66,7 @@ onMounted(() => store.fetchAll())
|
||||
<div class="notif-body">
|
||||
<p class="notif-msg">
|
||||
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
|
||||
{{ n.type === 'project_shared'
|
||||
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
|
||||
: n.type === 'note_shared'
|
||||
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
|
||||
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
|
||||
{{ notifMessage(n) }}
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Scheduler jobs for background maintenance tasks.
|
||||
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders.
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders and
|
||||
delivers them to the in-app notification feed.
|
||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
||||
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
|
||||
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
|
||||
recurring task whose spawn time has arrived.
|
||||
|
||||
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
||||
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,19 +57,26 @@ async def _fire_reminders() -> None:
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
# Deliver via the in-app notification feed (push was removed in Phase 8).
|
||||
from fabledassistant.services.notifications import create_in_app_notification
|
||||
|
||||
async with async_session() as session:
|
||||
for event in to_notify:
|
||||
# Push delivery removed alongside the chat subsystem in Phase 8.
|
||||
# Event reminders are still flagged via in-app notifications
|
||||
# (see services/notifications.py).
|
||||
|
||||
# Mark as sent regardless of push success to avoid re-firing
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event.id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if ev:
|
||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
||||
if ev is None or ev.reminder_sent_at is not None:
|
||||
continue
|
||||
await create_in_app_notification(ev.user_id, "event_reminder", {
|
||||
"event_id": ev.id,
|
||||
"title": ev.title,
|
||||
"start_dt": ev.start_dt.isoformat() if ev.start_dt else None,
|
||||
"url": "/calendar",
|
||||
})
|
||||
# Stamp only after the notification is created, so a delivery
|
||||
# failure leaves the reminder eligible to retry next sweep.
|
||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -91,6 +100,22 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recurring-task spawn job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_recurrence_spawn() -> None:
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
||||
try:
|
||||
await spawn_recurring_tasks()
|
||||
except Exception:
|
||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,8 +145,22 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Spawn the next occurrence of due recurring tasks every 15 minutes.
|
||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
||||
# never drained, so recurring tasks never recur.
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn_threadsafe,
|
||||
trigger=IntervalTrigger(minutes=15),
|
||||
args=[loop],
|
||||
id="recurrence_spawn",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
|
||||
logger.info(
|
||||
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
|
||||
"recurring-task spawn every 15m)"
|
||||
)
|
||||
|
||||
|
||||
def stop_event_scheduler() -> None:
|
||||
|
||||
@@ -266,12 +266,6 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic
|
||||
return n
|
||||
|
||||
|
||||
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
|
||||
# Push delivery was removed alongside the chat subsystem (Phase 8).
|
||||
# In-app notifications still flow through the bell-icon feed.
|
||||
return None
|
||||
|
||||
|
||||
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
@@ -325,7 +319,6 @@ async def notify_project_shared(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
|
||||
|
||||
|
||||
@@ -361,7 +354,6 @@ async def notify_note_shared(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
|
||||
|
||||
|
||||
@@ -385,7 +377,6 @@ async def notify_group_added(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
|
||||
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ async def spawn_recurring_tasks() -> int:
|
||||
and_(
|
||||
Note.recurrence_rule.isnot(None),
|
||||
Note.recurrence_next_spawn_at <= now,
|
||||
# Never spawn children off a trashed parent — that would
|
||||
# resurrect work the user explicitly deleted.
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user