Compare commits
No commits in common. "c503994be12f4bfdb9982b10e398a412cdfd8664" and "4209834d6dea4a68c5df5dd51567ac7ccfc993bf" have entirely different histories.
c503994be1
...
4209834d6d
|
|
@ -6044,8 +6044,6 @@
|
||||||
const controller = createPushNotifications({
|
const controller = createPushNotifications({
|
||||||
control:qs('#push-updates'),
|
control:qs('#push-updates'),
|
||||||
status:qs('#push-update-status'),
|
status:qs('#push-update-status'),
|
||||||
deadlineControl:qs('#push-deadlines'),
|
|
||||||
deadlineStatus:qs('#push-deadline-status'),
|
|
||||||
notification:window.Notification,
|
notification:window.Notification,
|
||||||
serviceWorker:navigator.serviceWorker,
|
serviceWorker:navigator.serviceWorker,
|
||||||
fetchJson:fetchReviewJson,
|
fetchJson:fetchReviewJson,
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,6 @@
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
|
||||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
else root.createPushNotifications = factory;
|
else root.createPushNotifications = factory;
|
||||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||||
control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson,
|
control, status, notification, serviceWorker, fetchJson,
|
||||||
}) {
|
}) {
|
||||||
let configuration = null;
|
let configuration = null;
|
||||||
|
|
||||||
|
|
@ -20,9 +20,7 @@
|
||||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||||
await subscription?.unsubscribe?.();
|
await subscription?.unsubscribe?.();
|
||||||
control.checked = false;
|
control.checked = false;
|
||||||
if (deadlineControl) deadlineControl.checked = false;
|
|
||||||
status.textContent = 'New update notifications are off for this device.';
|
status.textContent = 'New update notifications are off for this device.';
|
||||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enable() {
|
async function enable() {
|
||||||
|
|
@ -62,53 +60,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeDeadline() {
|
|
||||||
deadlineControl.disabled = true;
|
|
||||||
try {
|
|
||||||
const registration = await serviceWorker.ready;
|
|
||||||
const subscription = await registration.pushManager.getSubscription();
|
|
||||||
if (deadlineControl.checked && !subscription) {
|
|
||||||
deadlineControl.checked = false;
|
|
||||||
deadlineStatus.textContent = 'Enable new update notifications first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
||||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
|
||||||
method:'PUT',
|
|
||||||
headers:{'Content-Type':'application/json'},
|
|
||||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:9}),
|
|
||||||
});
|
|
||||||
deadlineStatus.textContent = deadlineControl.checked
|
|
||||||
? 'Deadline reminders enabled for 9:00 local time.'
|
|
||||||
: 'Deadline reminders are off for this device.';
|
|
||||||
} catch (error) {
|
|
||||||
deadlineControl.checked = !deadlineControl.checked;
|
|
||||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
|
||||||
} finally {
|
|
||||||
deadlineControl.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!control || !notification || !serviceWorker) return;
|
if (!control || !notification || !serviceWorker) return;
|
||||||
control.addEventListener('change', change);
|
control.addEventListener('change', change);
|
||||||
deadlineControl?.addEventListener('change', changeDeadline);
|
|
||||||
configuration = await fetchJson('api/v1/push-subscription');
|
configuration = await fetchJson('api/v1/push-subscription');
|
||||||
if (!configuration.available) {
|
if (!configuration.available) {
|
||||||
control.disabled = true;
|
control.disabled = true;
|
||||||
if (deadlineControl) deadlineControl.disabled = true;
|
|
||||||
status.textContent = 'New update notifications are not available on this server.';
|
status.textContent = 'New update notifications are not available on this server.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
control.checked = Boolean(configuration.subscribed);
|
control.checked = Boolean(configuration.subscribed);
|
||||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
|
||||||
status.textContent = configuration.subscribed
|
status.textContent = configuration.subscribed
|
||||||
? 'New update notifications enabled for this device.'
|
? 'New update notifications enabled for this device.'
|
||||||
: 'New update notifications are off for this device.';
|
: 'New update notifications are off for this device.';
|
||||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
|
||||||
? `Deadline reminders enabled for ${configuration.reminder_hour}:00 local time.`
|
|
||||||
: 'Deadline reminders are off for this device.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {init, change, changeDeadline};
|
return {init, change};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -316,24 +316,6 @@ self.addEventListener('push', event => {
|
||||||
const tag = String(payload.tag || '');
|
const tag = String(payload.tag || '');
|
||||||
const notificationId = Number(payload.notification_id);
|
const notificationId = Number(payload.notification_id);
|
||||||
const updateCount = Number(payload.update_count);
|
const updateCount = Number(payload.update_count);
|
||||||
const deadlineCount = Number(payload.deadline_count);
|
|
||||||
if (
|
|
||||||
route === '#/my-work/agenda'
|
|
||||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
|
||||||
&& Number.isSafeInteger(deadlineCount)
|
|
||||||
&& deadlineCount > 0
|
|
||||||
&& deadlineCount <= 50
|
|
||||||
) {
|
|
||||||
event.waitUntil(self.registration.showNotification(
|
|
||||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
|
||||||
{
|
|
||||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
|
||||||
tag,
|
|
||||||
data: {route},
|
|
||||||
}
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
route === '#/my-work/updates'
|
route === '#/my-work/updates'
|
||||||
&& tag === 'stackchain-update-digest'
|
&& tag === 'stackchain-update-digest'
|
||||||
|
|
|
||||||
|
|
@ -403,17 +403,6 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict:
|
|
||||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
|
||||||
items = []
|
|
||||||
for page in range(1, max_pages + 1):
|
|
||||||
result = await work_page("issue", page, limit)
|
|
||||||
items.extend(result["items"])
|
|
||||||
if not result["has_more"]:
|
|
||||||
return {"items": items, "complete": True}
|
|
||||||
return {"items": [], "complete": False}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
59
src/main.py
59
src/main.py
|
|
@ -48,11 +48,7 @@ from src.live_snapshot_store import (
|
||||||
)
|
)
|
||||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||||
from src.passkey_store import PasskeyStore
|
from src.passkey_store import PasskeyStore
|
||||||
from src.push_notifications import (
|
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||||
PushConfiguration,
|
|
||||||
dispatch_deadline_reminders,
|
|
||||||
dispatch_unread_updates,
|
|
||||||
)
|
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
|
|
@ -116,13 +112,6 @@ async def _push_poll_loop() -> None:
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
max_individual_notifications=max_individual_notifications,
|
max_individual_notifications=max_individual_notifications,
|
||||||
)
|
)
|
||||||
await dispatch_deadline_reminders(
|
|
||||||
_push_subscription_store,
|
|
||||||
_push_configuration(),
|
|
||||||
gitea_proxy.assigned_issue_snapshot,
|
|
||||||
session_active=dashboard_auth.managed_session_active,
|
|
||||||
send_timeout_seconds=send_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -357,23 +346,6 @@ class PushSubscriptionPayload(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DeadlineReminderPayload(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
timezone: str = Field(min_length=1, max_length=64)
|
|
||||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
|
||||||
|
|
||||||
@field_validator("timezone")
|
|
||||||
@classmethod
|
|
||||||
def validate_timezone(cls, value: str) -> str:
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
try:
|
|
||||||
ZoneInfo(value)
|
|
||||||
except ZoneInfoNotFoundError as error:
|
|
||||||
raise ValueError("Valid IANA timezone required") from error
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
StepUpAction = Literal[
|
StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
|
|
@ -1896,16 +1868,10 @@ async def push_status(request: Request):
|
||||||
_push_subscription_store.is_subscribed,
|
_push_subscription_store.is_subscribed,
|
||||||
device_id,
|
device_id,
|
||||||
)
|
)
|
||||||
preferences = await asyncio.to_thread(
|
|
||||||
_push_subscription_store.deadline_preferences, device_id
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"available": configuration.enabled,
|
"available": configuration.enabled,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"public_key": configuration.public_key if configuration.enabled else "",
|
"public_key": configuration.public_key if configuration.enabled else "",
|
||||||
"deadline_enabled": preferences["enabled"],
|
|
||||||
"timezone": preferences["timezone"],
|
|
||||||
"reminder_hour": preferences["reminder_hour"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1959,29 +1925,6 @@ async def unsubscribe_push(request: Request):
|
||||||
return {"subscribed": False}
|
return {"subscribed": False}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/v1/push-subscription/deadlines")
|
|
||||||
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
|
|
||||||
device_id = await dashboard_auth.session_management_id(
|
|
||||||
request.state.dashboard_session
|
|
||||||
)
|
|
||||||
if payload.enabled and not await asyncio.to_thread(
|
|
||||||
_push_subscription_store.is_subscribed, device_id
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_push_subscription_store.set_deadline_preferences,
|
|
||||||
device_id,
|
|
||||||
enabled=payload.enabled,
|
|
||||||
timezone=payload.timezone,
|
|
||||||
reminder_hour=payload.reminder_hour,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"deadline_enabled": payload.enabled,
|
|
||||||
"timezone": payload.timezone,
|
|
||||||
"reminder_hour": payload.reminder_hour,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/session/activity")
|
@app.post("/api/v1/session/activity")
|
||||||
async def record_session_activity(request: Request):
|
async def record_session_activity(request: Request):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import json
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||||
|
|
@ -236,97 +234,3 @@ async def dispatch_unread_updates(
|
||||||
return sum(counts)
|
return sum(counts)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_deadline_reminders(
|
|
||||||
store: PushSubscriptionStore,
|
|
||||||
configuration: PushConfiguration,
|
|
||||||
assigned: Callable[[], Awaitable[dict]],
|
|
||||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> int:
|
|
||||||
owner = secrets.token_urlsafe(18)
|
|
||||||
acquired = await asyncio.to_thread(
|
|
||||||
store.acquire_dispatch_lease,
|
|
||||||
owner,
|
|
||||||
now=time.time(),
|
|
||||||
lease_seconds=max(15.0, float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0),
|
|
||||||
)
|
|
||||||
if not acquired:
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
return await _dispatch_deadline_reminders_unlocked(
|
|
||||||
store, configuration, assigned, send, **kwargs
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_deadline_reminders_unlocked(
|
|
||||||
store: PushSubscriptionStore,
|
|
||||||
configuration: PushConfiguration,
|
|
||||||
assigned: Callable[[], Awaitable[dict]],
|
|
||||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
|
||||||
send_timeout_seconds: float = 10.0,
|
|
||||||
) -> int:
|
|
||||||
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
|
||||||
if not configuration.enabled:
|
|
||||||
return 0
|
|
||||||
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
|
||||||
if not devices:
|
|
||||||
return 0
|
|
||||||
snapshot = await assigned()
|
|
||||||
if snapshot.get("complete") is False:
|
|
||||||
return 0
|
|
||||||
current = now or datetime.now(timezone.utc)
|
|
||||||
due_cutoff = current + timedelta(hours=48)
|
|
||||||
due_count = 0
|
|
||||||
for item in snapshot.get("items", []):
|
|
||||||
if not isinstance(item, dict) or not item.get("due_date"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
due = datetime.fromisoformat(str(item["due_date"]).replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if due.tzinfo is None:
|
|
||||||
due = due.replace(tzinfo=timezone.utc)
|
|
||||||
if due <= due_cutoff:
|
|
||||||
due_count += 1
|
|
||||||
if not due_count:
|
|
||||||
return 0
|
|
||||||
delivered = 0
|
|
||||||
for device in devices:
|
|
||||||
try:
|
|
||||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
|
||||||
except ZoneInfoNotFoundError:
|
|
||||||
continue
|
|
||||||
local_day = local_now.date().isoformat()
|
|
||||||
if local_now.hour < device.reminder_hour or device.delivered_local_day == local_day:
|
|
||||||
continue
|
|
||||||
if session_active is not None and not await session_active(device.session_id):
|
|
||||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
|
||||||
continue
|
|
||||||
payload = json.dumps({
|
|
||||||
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
|
||||||
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
|
||||||
"route": "#/my-work/agenda",
|
|
||||||
"tag": f"stackchain-deadline-digest-{local_day}",
|
|
||||||
"deadline_count": due_count,
|
|
||||||
}, separators=(",", ":"))
|
|
||||||
try:
|
|
||||||
operation = (
|
|
||||||
send(device.subscription, payload)
|
|
||||||
if send is not None
|
|
||||||
else send_web_push(device.subscription, payload, configuration)
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
await asyncio.to_thread(
|
|
||||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
|
||||||
)
|
|
||||||
delivered += 1
|
|
||||||
return delivered
|
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,6 @@ class PushDelivery:
|
||||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DeadlineReminderDevice:
|
|
||||||
session_id: str
|
|
||||||
subscription: dict
|
|
||||||
timezone: str
|
|
||||||
reminder_hour: int
|
|
||||||
delivered_local_day: str | None
|
|
||||||
|
|
||||||
|
|
||||||
def _revisions(
|
def _revisions(
|
||||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||||
) -> tuple[tuple[int, str], ...]:
|
) -> tuple[tuple[int, str], ...]:
|
||||||
|
|
@ -87,15 +78,6 @@ class PushSubscriptionStore:
|
||||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
||||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
|
||||||
delivered_local_day TEXT,
|
|
||||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
delivery_columns = {
|
delivery_columns = {
|
||||||
|
|
@ -172,56 +154,6 @@ class PushSubscriptionStore:
|
||||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
def set_deadline_preferences(
|
|
||||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
|
||||||
) -> None:
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""INSERT INTO push_deadline_preferences(
|
|
||||||
session_id, enabled, timezone, reminder_hour
|
|
||||||
) VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(session_id) DO UPDATE SET
|
|
||||||
enabled = excluded.enabled,
|
|
||||||
timezone = excluded.timezone,
|
|
||||||
reminder_hour = excluded.reminder_hour""",
|
|
||||||
(session_id, int(enabled), timezone, reminder_hour),
|
|
||||||
)
|
|
||||||
|
|
||||||
def deadline_preferences(self, session_id: str) -> dict:
|
|
||||||
with self._connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"""SELECT enabled, timezone, reminder_hour
|
|
||||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchone()
|
|
||||||
return {
|
|
||||||
"enabled": bool(row[0]) if row else False,
|
|
||||||
"timezone": row[1] if row else "UTC",
|
|
||||||
"reminder_hour": row[2] if row else 9,
|
|
||||||
}
|
|
||||||
|
|
||||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
|
||||||
with self._connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
|
||||||
p.reminder_hour, p.delivered_local_day
|
|
||||||
FROM push_subscriptions s
|
|
||||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
|
||||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
|
||||||
WHERE session_id = ? AND enabled = 1""",
|
|
||||||
(local_day, session_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def claim_unseen(
|
def claim_unseen(
|
||||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||||
) -> list[PushDelivery]:
|
) -> list[PushDelivery]:
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src import gitea_proxy
|
|
||||||
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
|
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
||||||
store.upsert("device-a", {
|
|
||||||
"endpoint": "https://push.example/device-a",
|
|
||||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
||||||
})
|
|
||||||
store.set_deadline_preferences(
|
|
||||||
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
|
||||||
)
|
|
||||||
sent = []
|
|
||||||
|
|
||||||
async def assigned():
|
|
||||||
return {
|
|
||||||
"complete": True,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"title": "Private launch plan",
|
|
||||||
"repository": {"full_name": "private/repo"},
|
|
||||||
"due_date": "2026-08-14T12:00:00Z",
|
|
||||||
},
|
|
||||||
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send(_subscription, payload):
|
|
||||||
sent.append(json.loads(payload))
|
|
||||||
|
|
||||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
||||||
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
|
||||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
|
||||||
assert sent == [{
|
|
||||||
"title": "1 deadline needs attention",
|
|
||||||
"body": "Open Agenda to review or replan it.",
|
|
||||||
"route": "#/my-work/agenda",
|
|
||||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
|
||||||
"deadline_count": 1,
|
|
||||||
}]
|
|
||||||
assert "Private launch plan" not in json.dumps(sent)
|
|
||||||
assert "private/repo" not in json.dumps(sent)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
||||||
store.upsert("device-a", {
|
|
||||||
"endpoint": "https://push.example/device-a",
|
|
||||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
||||||
})
|
|
||||||
store.set_deadline_preferences(
|
|
||||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
|
||||||
)
|
|
||||||
sent = []
|
|
||||||
|
|
||||||
async def incomplete():
|
|
||||||
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
|
||||||
|
|
||||||
async def complete():
|
|
||||||
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
|
||||||
|
|
||||||
async def send(_subscription, payload):
|
|
||||||
sent.append(payload)
|
|
||||||
|
|
||||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
||||||
assert await dispatch_deadline_reminders(
|
|
||||||
store, config, incomplete, send,
|
|
||||||
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
|
||||||
) == 0
|
|
||||||
assert await dispatch_deadline_reminders(
|
|
||||||
store, config, complete, send,
|
|
||||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
|
||||||
) == 0
|
|
||||||
assert sent == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
|
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
||||||
store.upsert("device-a", {
|
|
||||||
"endpoint": "https://push.example/device-a",
|
|
||||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
||||||
})
|
|
||||||
|
|
||||||
store.set_deadline_preferences(
|
|
||||||
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
|
|
||||||
)
|
|
||||||
|
|
||||||
assert store.deadline_preferences("device-a") == {
|
|
||||||
"enabled": True,
|
|
||||||
"timezone": "Europe/London",
|
|
||||||
"reminder_hour": 8,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
|
||||||
pages = {
|
|
||||||
1: {"items": [{"id": 1}], "has_more": True},
|
|
||||||
2: {"items": [{"id": 2}], "has_more": False},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def work_page(stream, page, limit):
|
|
||||||
assert stream == "issue"
|
|
||||||
assert limit == 50
|
|
||||||
return pages[page]
|
|
||||||
|
|
||||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
|
||||||
|
|
||||||
assert await gitea_proxy.assigned_issue_snapshot() == {
|
|
||||||
"items": [{"id": 1}, {"id": 2}],
|
|
||||||
"complete": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
|
||||||
path = tmp_path / "push.sqlite3"
|
|
||||||
first = PushSubscriptionStore(path)
|
|
||||||
second = PushSubscriptionStore(path)
|
|
||||||
first.upsert("device-a", {
|
|
||||||
"endpoint": "https://push.example/device-a",
|
|
||||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
||||||
})
|
|
||||||
first.set_deadline_preferences(
|
|
||||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
|
||||||
)
|
|
||||||
sending = __import__("asyncio").Event()
|
|
||||||
release = __import__("asyncio").Event()
|
|
||||||
sent = []
|
|
||||||
|
|
||||||
async def assigned():
|
|
||||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
|
||||||
|
|
||||||
async def send(_subscription, payload):
|
|
||||||
sent.append(payload)
|
|
||||||
sending.set()
|
|
||||||
await release.wait()
|
|
||||||
|
|
||||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
||||||
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
|
||||||
active = __import__("asyncio").create_task(
|
|
||||||
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
|
||||||
)
|
|
||||||
await sending.wait()
|
|
||||||
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
|
||||||
release.set()
|
|
||||||
|
|
||||||
assert competing == 0
|
|
||||||
assert await active == 1
|
|
||||||
assert len(sent) == 1
|
|
||||||
|
|
@ -14,11 +14,6 @@ const control = {
|
||||||
checked:false, disabled:false,
|
checked:false, disabled:false,
|
||||||
addEventListener:(_name, callback) => state.change = callback,
|
addEventListener:(_name, callback) => state.change = callback,
|
||||||
};
|
};
|
||||||
const deadlineControl = {
|
|
||||||
checked:false, disabled:false,
|
|
||||||
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
|
||||||
};
|
|
||||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
|
||||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||||
const registration = {pushManager:{
|
const registration = {pushManager:{
|
||||||
|
|
@ -26,7 +21,7 @@ const registration = {pushManager:{
|
||||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||||
}};
|
}};
|
||||||
const feature = createPushNotifications({
|
const feature = createPushNotifications({
|
||||||
control, status, deadlineControl, deadlineStatus,
|
control, status,
|
||||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||||
serviceWorker: {ready:Promise.resolve(registration)},
|
serviceWorker: {ready:Promise.resolve(registration)},
|
||||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||||
|
|
@ -62,26 +57,6 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["text"] == "New update notifications enabled for this device."
|
assert result["text"] == "New update notifications enabled for this device."
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
|
|
||||||
result = run_scenario("""
|
|
||||||
state.current = existing;
|
|
||||||
state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'};
|
|
||||||
await feature.init();
|
|
||||||
deadlineControl.checked = true;
|
|
||||||
await state.deadlineChange();
|
|
||||||
process.stdout.write(JSON.stringify(state));
|
|
||||||
""")
|
|
||||||
|
|
||||||
assert result["prompts"] == 0
|
|
||||||
assert result["subscriptions"] == []
|
|
||||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"]
|
|
||||||
body = json.loads(result["requests"][-1][2])
|
|
||||||
assert body["enabled"] is True
|
|
||||||
assert body["reminder_hour"] == 9
|
|
||||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
|
||||||
assert result["deadlineText"] == "Deadline reminders enabled for 9:00 local time."
|
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
root = MODULE.parents[1]
|
root = MODULE.parents[1]
|
||||||
html = (root / "frontend" / "index.html").read_text()
|
html = (root / "frontend" / "index.html").read_text()
|
||||||
|
|
@ -93,8 +68,6 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
|
|
||||||
assert 'id="push-updates"' in html
|
assert 'id="push-updates"' in html
|
||||||
assert 'id="push-update-status"' in html
|
assert 'id="push-update-status"' in html
|
||||||
assert 'id="push-deadlines"' in html
|
|
||||||
assert 'id="push-deadline-status"' in html
|
|
||||||
assert '<script src="static/push-notifications.js"></script>' in html
|
assert '<script src="static/push-notifications.js"></script>' in html
|
||||||
assert "createPushNotifications({" in dashboard
|
assert "createPushNotifications({" in dashboard
|
||||||
assert "BASE + 'static/push-notifications.js'" in worker
|
assert "BASE + 'static/push-notifications.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -819,12 +819,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await main.push_status(request) == {
|
assert await main.push_status(request) == {
|
||||||
"available": True,
|
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||||
"subscribed": False,
|
|
||||||
"public_key": "public-vapid",
|
|
||||||
"deadline_enabled": False,
|
|
||||||
"timezone": "UTC",
|
|
||||||
"reminder_hour": 9,
|
|
||||||
}
|
}
|
||||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
assert (await main.push_status(request))["subscribed"] is True
|
assert (await main.push_status(request))["subscribed"] is True
|
||||||
|
|
@ -832,32 +827,6 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
assert (await main.push_status(request))["subscribed"] is False
|
assert (await main.push_status(request))["subscribed"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
||||||
store.upsert("session-a", {
|
|
||||||
"endpoint": "https://push.example/device-a",
|
|
||||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
||||||
})
|
|
||||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
||||||
|
|
||||||
async def management_id(_session):
|
|
||||||
return "session-a"
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
||||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
||||||
payload = main.DeadlineReminderPayload(
|
|
||||||
enabled=True, timezone="America/New_York", reminder_hour=9
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await main.update_deadline_reminders(payload, request) == {
|
|
||||||
"deadline_enabled": True,
|
|
||||||
"timezone": "America/New_York",
|
|
||||||
"reminder_hour": 9,
|
|
||||||
}
|
|
||||||
assert store.deadline_preferences("session-a")["enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
|
||||||
|
|
@ -459,32 +459,6 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_digest_push_opens_agenda_without_rendering_private_copy():
|
|
||||||
result = run_worker_scenario(
|
|
||||||
"""
|
|
||||||
await dispatchPush({
|
|
||||||
title:'must-not-render', body:'private details must-not-render',
|
|
||||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', deadline_count:3,
|
|
||||||
});
|
|
||||||
await dispatchNotificationClick('#/my-work/agenda');
|
|
||||||
process.stdout.write(JSON.stringify(state));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["notifications"] == [{
|
|
||||||
"title": "3 deadlines need attention",
|
|
||||||
"options": {
|
|
||||||
"body": "Open Agenda to review or replan them.",
|
|
||||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
|
||||||
"data": {"route": "#/my-work/agenda"},
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
assert result["opened"] == [
|
|
||||||
"https://forge.example/dashboard/#/my-work/agenda"
|
|
||||||
]
|
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user