File size: 7,254 Bytes
0a367ef
4c83ab6
0a367ef
 
 
4c83ab6
 
 
 
0a367ef
 
 
 
 
 
d618a6e
0a367ef
4c83ab6
 
 
 
 
 
 
 
 
 
780bb45
 
 
 
 
 
4c83ab6
780bb45
4c83ab6
 
 
 
 
 
 
 
0a367ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c83ab6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a367ef
4c83ab6
0a367ef
 
 
 
 
 
 
 
 
 
4c83ab6
 
 
0a367ef
4c83ab6
 
 
 
 
 
780bb45
4c83ab6
 
 
 
 
 
 
 
0a367ef
 
 
 
 
 
 
 
 
 
 
 
 
 
d618a6e
0a367ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c83ab6
 
 
0a367ef
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import json
import logging
import traceback
from datetime import datetime, timezone
from pathlib import Path

from apscheduler.schedulers.asyncio import AsyncIOScheduler

from app.config import Settings
from app.database import (
    get_db,
    get_pending_brief_items,
    mark_brief_items_sent,
    run_retention_cleanup,
)
from app.lib.utils.brief_html import build_brief_html
from app.lib.utils.notifier import _build_sendmail_payload, send_developer_alert

logger = logging.getLogger(__name__)


class RenewalScheduler:
    def __init__(self, subscription_manager, settings: Settings) -> None:
        self._scheduler = AsyncIOScheduler()
        self._manager = subscription_manager
        self._settings = settings

    def _renewal_interval_seconds(self) -> int:
        # Renew at 80% of the subscription lifetime minus 15 minutes.
        # With the default 4230-minute expiry that fires roughly every 53.5 hours,
        # well before Graph's expiry and the 60-minute cutoff in ensure_subscription.
        return int(self._settings.subscription_expiry_minutes * 60 * 0.80) - 900

    def start(self, subscription_id: str) -> None:
        interval_seconds = self._renewal_interval_seconds()
        self._scheduler.add_job(
            self._renew_job,
            trigger="interval",
            seconds=interval_seconds,
            id="subscription_renewal",
            replace_existing=True,
            args=[subscription_id],
        )
        self._scheduler.add_job(
            self._send_daily_brief,
            trigger="cron",
            hour=18,
            minute=0,
            timezone="America/Chicago",
            id="daily_brief",
            replace_existing=True,
        )
        self._scheduler.add_job(
            self._run_retention,
            trigger="cron",
            hour=2,
            minute=0,
            id="retention_cleanup",
            replace_existing=True,
        )
        self._scheduler.start()
        logger.info(
            "Renewal scheduler started; will renew subscription every %d seconds",
            interval_seconds,
        )

    async def _renew_job(self, subscription_id: str) -> None:
        logger.info("Renewing subscription %s", subscription_id)
        try:
            await self._manager.renew_subscription(subscription_id)
        except Exception:
            logger.warning(
                "Renewal failed for %s; attempting to recreate subscription", subscription_id
            )
            try:
                folder_id = await self._get_folder_id()
                new_id = await self._manager.ensure_subscription(folder_id)
                self._reschedule(new_id)
            except Exception:
                tb = traceback.format_exc()
                logger.exception("Failed to recreate subscription after renewal failure")
                await send_developer_alert(
                    self._settings,
                    subject="[RCM] Subscription renewal and recreation both failed",
                    body=(
                        f"Subscription ID: {subscription_id}\n\n"
                        f"Renewal failed and the attempt to recreate the subscription "
                        f"also failed. Email notifications are NOT being received.\n\n"
                        f"{tb}"
                    ),
                )

    async def _get_folder_id(self) -> str:
        async with get_db(self._manager._db_path) as conn:
            from app.database import get_active_subscription
            sub = await get_active_subscription(conn)
            if sub:
                return sub["folder_id"]
        raise RuntimeError("No active subscription found to determine folder_id for recreation")

    def _reschedule(self, new_subscription_id: str) -> None:
        interval_seconds = self._renewal_interval_seconds()
        self._scheduler.reschedule_job(
            "subscription_renewal",
            trigger="interval",
            seconds=interval_seconds,
            args=[new_subscription_id],
        )
        logger.info("Rescheduled renewal for new subscription %s", new_subscription_id)

    async def _send_daily_brief(self) -> None:
        db_path = self._settings.database_path
        async with get_db(db_path) as conn:
            items = await get_pending_brief_items(conn)

        if not items:
            logger.info("Daily brief: no pending items, skipping send")
            return

        recipients = self._settings.brief_recipient_list()
        if not recipients:
            logger.warning("Daily brief: BRIEF_RECIPIENTS not configured, skipping send")
            return

        html = build_brief_html(items)
        path, payload = _build_sendmail_payload(
            recipients=recipients,
            subject=f"RCM Email Automation — Daily Brief {datetime.now(timezone.utc).strftime('%Y-%m-%d')}",
            body_html=html,
            mailbox_user=self._settings.mailbox_user,
        )

        try:
            await self._manager._client.post(path, payload)
            ids = [item["id"] for item in items]
            async with get_db(db_path) as conn:
                await mark_brief_items_sent(conn, ids)
            logger.info("Daily brief sent to %d recipient(s), %d item(s)", len(recipients), len(items))
        except Exception:
            tb = traceback.format_exc()
            logger.exception("Daily brief send failed; items will retry tomorrow")
            await send_developer_alert(
                self._settings,
                subject="[RCM] Daily brief send failed",
                body=(
                    f"The daily brief could not be sent. "
                    f"{len(items)} item(s) remain pending and will retry tomorrow.\n\n"
                    f"{tb}"
                ),
            )

    async def _run_retention(self) -> None:
        db_path = self._settings.database_path
        try:
            async with get_db(db_path) as conn:
                await run_retention_cleanup(conn)
            logger.info("Retention cleanup complete")
        except Exception:
            tb = traceback.format_exc()
            logger.exception("Retention cleanup failed")
            await send_developer_alert(
                self._settings,
                subject="[RCM] Retention cleanup failed",
                body=f"The scheduled retention cleanup job raised an exception.\n\n{tb}",
            )

        try:
            _cleanup_log_files(self._settings.log_path)
        except Exception:
            logger.exception("Log file cleanup failed")

    def shutdown(self) -> None:
        if self._scheduler.running:
            self._scheduler.shutdown(wait=False)


def _cleanup_log_files(log_path: Path) -> None:
    cutoff = datetime.now(timezone.utc).replace(tzinfo=None)
    for log_file in log_path.glob("processor.log.*"):
        suffix = log_file.suffix.lstrip(".")
        try:
            file_date = datetime.strptime(suffix, "%Y-%m-%d")
            age_days = (cutoff - file_date).days
            if age_days > 365:
                log_file.unlink()
                logger.info("Deleted old log file %s", log_file)
        except ValueError:
            logger.debug("Skipping log file with unrecognised date suffix: %s", log_file)