"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels). Reminders live on database rows, next to the recurrence rule, inside ``property_values_json`` under the special key ``__reminder__``:: "__reminder__": { "": {"value": 30, "unit": "minutes"} } A background loop scans rows with reminders, computes the next occurrence (recurrence-aware, timezone-aware), and fires an in-app notification (+ email when the user opted in) at ``event - lead``. Fired reminders are deduplicated through the ``reminder_log`` table keyed by (page_id, occurrence date). The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can drive it deterministically with an injected ``now``. """ from __future__ import annotations import datetime as dt import json import logging from app.db import get_conn from app.services.recurrence import ( RECURRENCE_KEY, TIMEZONE_KEY, next_occurrences, parse_date, ) logger = logging.getLogger(__name__) REMINDER_KEY = "__reminder__" _UNITS = {"minutes": 60, "hours": 3600, "days": 86400} # Fire late is still useful; ignore events that happened long ago. FIRE_GRACE_SECONDS = 3600 def parse_lead(reminder) -> int | None: """Return the lead in seconds for a reminder dict, or None if disabled.""" if not isinstance(reminder, dict): return None unit = reminder.get("unit", "none") if unit not in _UNITS: return None try: value = int(reminder.get("value", 0)) except (TypeError, ValueError): return None if value <= 0: return None return value * _UNITS[unit] def lead_human(reminder: dict) -> str: try: value = int(reminder.get("value", 0)) except (TypeError, ValueError): return "" unit = reminder.get("unit", "") if value == 1 and unit.endswith("s"): unit = unit[:-1] return f"{value} {unit}" def _person_targets(conn, collection_id: int, props: dict) -> list[int]: """User ids assigned via any ``person`` property of the row.""" targets: list[int] = [] person_props = conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'", (collection_id,), ).fetchall() for pp in person_props: value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None) if isinstance(value, list): for person in value: if isinstance(person, dict) and person.get("id"): targets.append(int(person["id"])) return targets def _row_tz(conn, collection_id: int, props: dict) -> str: """Effective timezone of a row: per-event ``__timezone__`` first, then the recurrence rule tz, then the created_by user's personal timezone, then '' (UTC).""" tzmap = props.get(TIMEZONE_KEY) if isinstance(tzmap, dict): for value in tzmap.values(): if value: return str(value) rec = props.get(RECURRENCE_KEY) or {} for rule in rec.values(): if isinstance(rule, dict) and rule.get("timezone"): return rule["timezone"] auto_props = conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'", (collection_id,), ).fetchall() for ap in auto_props: value = props.get(str(ap["id"])) people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else []) for person in people: if isinstance(person, dict) and person.get("id"): row = conn.execute( "SELECT timezone FROM users WHERE id=?", (person["id"],) ).fetchone() if row and row["timezone"]: return row["timezone"] return "" def scan_and_fire(now: dt.datetime | None = None) -> int: """Fire every reminder that is due. Returns the number of notifications created. Safe to call repeatedly (dedup via reminder_log).""" now = now or dt.datetime.now(dt.UTC) fired = 0 with get_conn() as conn: pages = conn.execute( "SELECT id, collection_id, title, property_values_json FROM collection_pages" ).fetchall() for page in pages: try: props = json.loads(page["property_values_json"] or "{}") except (json.JSONDecodeError, TypeError): continue reminders = props.get(REMINDER_KEY) if not isinstance(reminders, dict): continue rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {} for prop_key, reminder in reminders.items(): lead = parse_lead(reminder) if lead is None: continue base_value = props.get(str(prop_key)) if not base_value and str(prop_key).isdigit(): # fall back to name-keyed storage pname = conn.execute( "SELECT name FROM collection_properties WHERE id=?", (int(prop_key),) ).fetchone() if pname: base_value = props.get(pname["name"]) if parse_date(base_value) is None: continue rule = rec_all.get(str(prop_key)) if rule is None and not str(prop_key).isdigit(): pid = conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? AND name=?", (page["collection_id"], str(prop_key)), ).fetchone() if pid: rule = rec_all.get(str(pid["id"])) tz_name = _row_tz(conn, page["collection_id"], props) # Look for the occurrence whose reminder moment is in # [now - grace, now]. events = next_occurrences( base_value, rule, tz_name or None, after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS), horizon_days=400, max_count=2000, ) due = None for _d, event in events: fire_at = event - dt.timedelta(seconds=lead) if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS): due = (event, fire_at) break if due is None: continue event, _fire_at = due if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS): continue target_ids = _person_targets(conn, page["collection_id"], props) if not target_ids: row = conn.execute( "SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1" ).fetchone() target_ids = [row["id"]] if row else [] for uid in dict.fromkeys(target_ids): cur = conn.execute( "INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)", (page["id"], event.date().isoformat()), ) if cur.rowcount != 1: continue from app.services import mailer, notifications lead_text = lead_human(reminder) notifications.create_notification( uid, None, "reminder", title=f"Reminder: {page['title'] or 'Untitled'}", message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}" if lead else f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"), resource_type="db_page", resource_id=page["id"], url=f"/collections/{page['collection_id']}", conn=conn, commit=False, ) mailer.notify_user( uid, subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}", body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).", cta_url=f"/collections/{page['collection_id']}", prefs_key="reminders", ) fired += 1 conn.commit() return fired async def reminder_scheduler(interval_seconds: int = 60): """Background loop: scan for due reminders once a minute.""" import asyncio from app.config import settings if not getattr(settings, "reminders_enabled", True): logger.info("Reminder scheduler disabled via settings") return await asyncio.sleep(5) while True: try: n = scan_and_fire() if n: logger.info("Reminders: fired %d notification(s)", n) except Exception as exc: # pragma: no cover - defensive only logger.warning("Reminder scan failed: %s", exc) await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))