diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 2226a31..4f1b4e3 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -661,6 +661,7 @@ const humanGatesOnChange = (snapshot, state)=>{ queueCounts.gate = snapshot.pending_count; + appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true); queueCounts.gateUnavailable = state.available === false; preparationItems.gate = snapshot.items; mobileTaskDock.updateQueues(queueCounts); @@ -8433,6 +8434,8 @@ startDayHour:qs('#push-start-day-hour'), followingControl:qs('#push-following'), followingStatus:qs('#push-following-status'), + humanGateControl:qs('#push-human-gates'), + humanGateStatus:qs('#push-human-gates-status'), deadlineSnooze:qs('#deadline-snooze'), deadlineSnoozeStatus:qs('#deadline-snooze-status'), deadlineSnoozeReview:qs('#review-snoozed-deadlines'), diff --git a/frontend/index.html b/frontend/index.html index 22585a0..d77ea2c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -233,6 +233,8 @@ + +
diff --git a/frontend/mobile-app-badge.js b/frontend/mobile-app-badge.js index 5aa9331..04fe794 100644 --- a/frontend/mobile-app-badge.js +++ b/frontend/mobile-app-badge.js @@ -11,7 +11,7 @@ }) { const ENABLED_KEY = 'stackchain.app-badge.enabled.v1'; let enabled = false; - const confirmedCounts = {updates:0, following:0}; + const confirmedCounts = {updates:0, following:0, 'human-gates':0}; let renderedCount = null; @@ -39,7 +39,9 @@ } async function render() { - const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following); + const confirmedCount = Math.min( + 9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates'] + ); if (!enabled || !available() || renderedCount === confirmedCount) return true; try { if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount); @@ -61,10 +63,12 @@ if (enabled) { if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates); if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following); + if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']); } if (!enabled && available()) { confirmedCounts.updates = 0; confirmedCounts.following = 0; + confirmedCounts['human-gates'] = 0; try { await navigator.clearAppBadge(); renderedCount = null; diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 03ea3a8..5d038c2 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -5,6 +5,7 @@ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, followingControl, followingStatus, + humanGateControl, humanGateStatus, quietControl = globalThis.document?.querySelector('#push-quiet-hours'), quietStart = globalThis.document?.querySelector('#push-quiet-start'), quietEnd = globalThis.document?.querySelector('#push-quiet-end'), @@ -135,14 +136,17 @@ if (deadlineControl) deadlineControl.checked = false; if (startDayControl) startDayControl.checked = false; if (followingControl) followingControl.checked = false; + if (humanGateControl) humanGateControl.checked = false; configuration.subscribed = false; configuration.deadline_enabled = false; configuration.start_day_enabled = false; configuration.following_enabled = false; + configuration.human_gates_enabled = false; pendingIntent = null; status.textContent = 'New update notifications are off for this device.'; if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.'; + if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts are off for this device.'; } async function ensureSubscription() { @@ -306,6 +310,38 @@ } } + async function changeHumanGates() { + humanGateControl.disabled = true; + try { + const registration = await serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + if (humanGateControl.checked) pendingIntent = 'human-gates'; + if (humanGateControl.checked && !subscription) subscription = await ensureSubscription(); + if (humanGateControl.checked && !subscription) { + humanGateControl.checked = false; + humanGateStatus.textContent = status.textContent; + return false; + } + await fetchJson('api/v1/push-subscription/human-gates', { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({enabled:humanGateControl.checked}), + }); + configuration.human_gates_enabled = humanGateControl.checked; + pendingIntent = null; + humanGateStatus.textContent = humanGateControl.checked + ? 'Human Gate decision alerts enabled for this device.' + : 'Human Gate decision alerts are off for this device.'; + return true; + } catch (_error) { + humanGateControl.checked = !humanGateControl.checked; + humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.'; + return false; + } finally { + humanGateControl.disabled = false; + } + } + async function changeQuietHours() { if (!quietControl) return false; for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true; @@ -342,7 +378,7 @@ } async function recoverPermission(intent = null) { - if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent; + if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].includes(intent)) pendingIntent = intent; if (!pendingIntent || notification.permission !== 'granted') return false; if (recoveryPromise) return recoveryPromise; recoveryPromise = (async () => { @@ -358,6 +394,10 @@ followingControl.checked = true; return changeFollowing(); } + if (pendingIntent === 'human-gates') { + humanGateControl.checked = true; + return changeHumanGates(); + } return Boolean(await enable()); })(); try { @@ -374,6 +414,7 @@ deadlineControl?.addEventListener('change', changeDeadline); startDayControl?.addEventListener('change', changeStartDay); followingControl?.addEventListener('change', changeFollowing); + humanGateControl?.addEventListener('change', changeHumanGates); quietControl?.addEventListener('change', changeQuietHours); quietStart?.addEventListener('change', changeQuietHours); quietEnd?.addEventListener('change', changeQuietHours); @@ -384,6 +425,7 @@ if (deadlineControl) deadlineControl.disabled = true; if (startDayControl) startDayControl.disabled = true; if (followingControl) followingControl.disabled = true; + if (humanGateControl) humanGateControl.disabled = true; if (quietControl) quietControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; @@ -392,6 +434,7 @@ if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled); if (followingControl) followingControl.checked = Boolean(configuration.following_enabled); + if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled); if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled); if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00'; if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00'; @@ -408,11 +451,14 @@ if (followingStatus) followingStatus.textContent = configuration.following_enabled ? 'Following change alerts enabled for this device.' : 'Following change alerts are off for this device.'; + if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled + ? 'Human Gate decision alerts enabled for this device.' + : 'Human Gate decision alerts are off for this device.'; if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled ? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.` : 'Routine alert quiet hours are off for this device.'; renderDeadlineSnooze(); } - return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; + return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 587323b..2a51700 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -43,7 +43,7 @@ function createAppBadgePreference() { async getCounts() { const database = await open(); const counts = {}; - for (const channel of ['updates', 'following']) { + for (const channel of ['updates', 'following', 'human-gates']) { counts[channel] = await new Promise((resolve, reject) => { const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel); request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0); @@ -67,6 +67,7 @@ function createAppBadgePreference() { async clearCounts() { await this.setCount('updates', 0); await this.setCount('following', 0); + await this.setCount('human-gates', 0); }, }; } @@ -74,7 +75,7 @@ const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBa let renderedBackgroundBadgeCount = null; async function reconcileBackgroundAppBadge(channel, count) { - if (!['updates', 'following'].includes(channel) + if (!['updates', 'following', 'human-gates'].includes(channel) || !Number.isSafeInteger(count) || count < 0 || count > 9999 || typeof self.registration.setAppBadge !== 'function' || typeof self.registration.clearAppBadge !== 'function') return false; @@ -84,7 +85,7 @@ async function reconcileBackgroundAppBadge(channel, count) { try { await appBadgePreference.setCount(channel, count); const counts = await appBadgePreference.getCounts(); - const total = Math.min(9999, counts.updates + counts.following); + const total = Math.min(9999, counts.updates + counts.following + counts['human-gates']); if (renderedBackgroundBadgeCount === total) return false; if (total > 0) await self.registration.setAppBadge(total); else await self.registration.clearAppBadge(); @@ -623,7 +624,7 @@ self.addEventListener('message', event => { if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return; const channel = event.data.channel; const count = event.data.count; - if (!['updates', 'following'].includes(channel) + if (!['updates', 'following', 'human-gates'].includes(channel) || !Number.isSafeInteger(count) || count < 0 || count > 9999) return; try { if (await appBadgePreference.get()) { @@ -708,6 +709,7 @@ self.addEventListener('push', event => { const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN; const deadlineCount = Number(payload.deadline_count); const followingCount = Number(payload.following_count); + const humanGateCount = Number(payload.human_gate_count); const planDate = String(payload.plan_date || ''); if ( route === '#/my-work/start-day' @@ -744,6 +746,25 @@ self.addEventListener('push', event => { )); return; } + if ( + route === '#/my-work/human-gates' + && tag === 'stackchain-human-gates-' + humanGateCount + && Number.isSafeInteger(humanGateCount) + && humanGateCount > 0 + && humanGateCount <= 50 + ) { + event.waitUntil(Promise.all([ + reconcileBackgroundAppBadge('human-gates', humanGateCount), + self.registration.showNotification( + humanGateCount + ' release decision' + (humanGateCount === 1 ? ' is' : 's are') + ' waiting', { + body: 'Open Human Gates to review ' + (humanGateCount === 1 ? 'it.' : 'them.'), + tag, + data: {route}, + } + ), + ])); + return; + } if ( route === '#/my-work/following' && /^stackchain-following-[0-9a-f]{16}$/.test(tag) diff --git a/src/main.py b/src/main.py index b66110e..6eab560 100644 --- a/src/main.py +++ b/src/main.py @@ -62,6 +62,7 @@ from src.push_notifications import ( PushConfiguration, dispatch_deadline_reminders, dispatch_following_changes, + dispatch_human_gate_changes, dispatch_start_day_reminders, dispatch_unread_updates, send_web_push, @@ -142,6 +143,18 @@ async def _following_push_snapshot() -> dict: return await get_following(Response()) +async def _human_gate_push_snapshot() -> dict: + user = await current_user() + login = user.get("login") if isinstance(user, dict) else None + if not isinstance(login, str) or not login: + return {"complete": False} + result = await asyncio.to_thread( + _human_gate_store().list, login, state="pending", limit=100 + ) + items = result.get("items", []) if isinstance(result, dict) else [] + return {"complete": True, "count": len(items)} + + async def _identity_bound_push_session_statuses( management_ids: list[str], ) -> dict[str, str]: @@ -207,6 +220,17 @@ async def _push_poll_loop() -> None: max_concurrency=max_concurrency, ) + async def dispatch_human_gates() -> None: + await dispatch_human_gate_changes( + _push_subscription_store, + _push_configuration(), + _human_gate_push_snapshot, + session_statuses=_identity_bound_push_session_statuses, + send_timeout_seconds=send_timeout, + lease_seconds=lease_seconds, + max_concurrency=max_concurrency, + ) + async def dispatch_start_day() -> None: await dispatch_start_day_reminders( _push_subscription_store, @@ -221,6 +245,7 @@ async def _push_poll_loop() -> None: channel_tasks = ( asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)), asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)), + asyncio.create_task(_push_channel_loop(dispatch_human_gates, interval=interval)), asyncio.create_task( _push_channel_loop(dispatch_deadlines, interval=deadline_interval) ), @@ -537,6 +562,10 @@ class FollowingNotificationPayload(BaseModel): enabled: bool +class HumanGateNotificationPayload(BaseModel): + enabled: bool + + class QuietHoursPayload(BaseModel): enabled: bool start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$") @@ -2712,6 +2741,9 @@ async def push_status(request: Request): following_preferences = await asyncio.to_thread( _push_subscription_store.following_preferences, device_id ) + human_gate_preferences = await asyncio.to_thread( + _push_subscription_store.human_gate_preferences, device_id + ) quiet_hours = await asyncio.to_thread( _push_subscription_store.quiet_hours, device_id ) @@ -2731,6 +2763,7 @@ async def push_status(request: Request): "start_day_timezone": start_day_preferences["timezone"], "start_day_reminder_hour": start_day_preferences["reminder_hour"], "following_enabled": following_preferences["enabled"], + "human_gates_enabled": human_gate_preferences["enabled"], "quiet_hours_enabled": quiet_hours["enabled"], "quiet_hours_start": quiet_hours["start"], "quiet_hours_end": quiet_hours["end"], @@ -2902,6 +2935,25 @@ async def update_following_notifications( return {"following_enabled": payload.enabled} +@app.put("/api/v1/push-subscription/human-gates") +async def update_human_gate_notifications( + payload: HumanGateNotificationPayload, 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_human_gate_preferences, + device_id, + enabled=payload.enabled, + ) + return {"human_gates_enabled": payload.enabled} + + @app.put("/api/v1/push-subscription/quiet-hours") async def update_quiet_hours(payload: QuietHoursPayload, request: Request): device_id = await dashboard_auth.session_management_id( diff --git a/src/push_notifications.py b/src/push_notifications.py index ae6a59c..8376868 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -240,6 +240,104 @@ async def dispatch_following_changes( await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following") +async def dispatch_human_gate_changes( + store: PushSubscriptionStore, + configuration: PushConfiguration, + pending_gates: Callable[[], Awaitable[dict]], + send: Callable[[dict, str], Awaitable[None]] | None = None, + *, + session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None, + lease_seconds: float = 60.0, + send_timeout_seconds: float = 10.0, + max_concurrency: int = 8, + now: float | None = None, +) -> int: + """Notify opted-in active devices when the pending Human Gate count changes.""" + if not configuration.enabled: + return 0 + owner = secrets.token_urlsafe(18) + acquired = await asyncio.to_thread( + store.acquire_dispatch_lease, + owner, + channel="human-gates", + now=time.time() if now is None else now, + lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0), + ) + if not acquired: + return 0 + try: + devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now) + if not devices: + return 0 + snapshot = await pending_gates() + if not isinstance(snapshot, dict) or snapshot.get("complete") is False: + return 0 + count = snapshot.get("count") + if not isinstance(count, int) or isinstance(count, bool) or count < 0: + return 0 + count = min(count, 50) + pending = [device for device in devices if device.delivered_count != count] + if not pending: + return 0 + if session_statuses is not None: + try: + statuses = await session_statuses([device.session_id for device in pending]) + except Exception: + return 0 + for device in pending: + if statuses.get(device.session_id) != "active": + await asyncio.to_thread(store.delete_session, device.session_id) + pending = [device for device in pending if statuses.get(device.session_id) == "active"] + if count == 0: + await asyncio.gather(*( + asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0) + for device in pending + )) + return 0 + semaphore = asyncio.Semaphore(max(1, max_concurrency)) + + async def dispatch_device(device) -> int: + async with semaphore: + payload = json.dumps({ + "title": f"{count} release decision{' is' if count == 1 else 's are'} waiting", + "body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.", + "route": "#/my-work/human-gates", + "tag": f"stackchain-human-gates-{count}", + "human_gate_count": 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 as error: + status = getattr(getattr(error, "response", None), "status_code", None) + if isinstance(error, UnsafePushEndpoint) or status in {404, 410}: + await asyncio.to_thread(store.delete_session, device.session_id) + else: + await asyncio.to_thread( + store.mark_delivery_failed, device.session_id, "human-gates", + _delivery_failure_reason(error), + ) + return 0 + await asyncio.to_thread( + store.mark_delivery_succeeded, device.session_id, "human-gates" + ) + await asyncio.to_thread( + store.mark_human_gate_delivered, device.session_id, count + ) + return 1 + + results = await asyncio.gather( + *(dispatch_device(device) for device in pending), return_exceptions=True + ) + return sum(result for result in results if isinstance(result, int)) + finally: + await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates") + + async def dispatch_unread_updates( store: PushSubscriptionStore, configuration: PushConfiguration, diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 189a2b8..54847e8 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -63,6 +63,14 @@ class FollowingNotificationDevice: catch_up: bool = False +@dataclass(frozen=True) +class HumanGateNotificationDevice: + session_id: str + subscription: dict + delivered_count: int + catch_up: bool = False + + class DisabledPushSubscriptionStore: """No-persistence store used when Web Push is not configured.""" @@ -95,12 +103,18 @@ class DisabledPushSubscriptionStore: def following_preferences(self, session_id: str) -> dict: return {"enabled": False} + def human_gate_preferences(self, session_id: str) -> dict: + return {"enabled": False} + def quiet_hours(self, session_id: str) -> dict: return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"} def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]: return [] + def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]: + return [] + def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]: return [] @@ -140,12 +154,18 @@ class DisabledPushSubscriptionStore: def set_following_preferences(self, *args, **kwargs) -> None: return None + def set_human_gate_preferences(self, *args, **kwargs) -> None: + return None + def set_quiet_hours(self, *args, **kwargs) -> None: return None def mark_following_delivered(self, *args, **kwargs) -> None: return None + def mark_human_gate_delivered(self, *args, **kwargs) -> None: + return None + def reconcile_unread(self, *args, **kwargs) -> None: return None @@ -282,6 +302,13 @@ class PushSubscriptionStore: FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS push_human_gate_preferences ( + session_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + delivered_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) + ON DELETE CASCADE + ); CREATE TABLE IF NOT EXISTS push_quiet_hours ( session_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, @@ -642,6 +669,23 @@ class PushSubscriptionStore: ).fetchone() return {"enabled": bool(row[0]) if row else False} + def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None: + with self._connect() as connection: + connection.execute( + """INSERT INTO push_human_gate_preferences(session_id, enabled) + VALUES (?, ?) + ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""", + (session_id, int(enabled)), + ) + + def human_gate_preferences(self, session_id: str) -> dict: + with self._connect() as connection: + row = connection.execute( + "SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?", + (session_id,), + ).fetchone() + return {"enabled": bool(row[0]) if row else False} + def set_quiet_hours( self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str ) -> None: @@ -713,6 +757,46 @@ class PushSubscriptionStore: (session_id,), ) + def human_gate_notification_devices( + self, *, now: float | None = None + ) -> list[HumanGateNotificationDevice]: + checked_at = time.time() if now is None else now + with self._connect() as connection: + rows = connection.execute( + """SELECT s.session_id, s.subscription_json, p.delivered_count, + q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed + FROM push_subscriptions s + JOIN push_human_gate_preferences p ON p.session_id = s.session_id + LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id + WHERE p.enabled = 1 ORDER BY s.session_id""" + ).fetchall() + devices = [] + for row in rows: + if row[3] and _inside_quiet_hours( + now=checked_at, start=row[4], end=row[5], timezone=row[6] + ): + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?", + (row[0],), + ) + continue + devices.append(HumanGateNotificationDevice( + row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7]) + )) + return devices + + def mark_human_gate_delivered(self, session_id: str, count: int) -> None: + with self._connect() as connection: + connection.execute( + """UPDATE push_human_gate_preferences SET delivered_count = ? + WHERE session_id = ? AND enabled = 1""", + (count, session_id), + ) + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?", + (session_id,), + ) + def claim_unseen( self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]], *, now: float | None = None, diff --git a/tests/test_mobile_app_badge.py b/tests/test_mobile_app_badge.py index 849c217..85da973 100644 --- a/tests/test_mobile_app_badge.py +++ b/tests/test_mobile_app_badge.py @@ -88,6 +88,27 @@ console.log(JSON.stringify({values, clears})); assert result == {"values": [3, 5, 3, 2, 7], "clears": 1} +def test_app_badge_combines_pending_human_gates_with_other_review_channels(): + result = run_badge(""" +const values = []; +let clears = 0; +const controller = createMobileAppBadge({ + control:{checked:true, disabled:false, addEventListener() {}}, + status:{textContent:''}, container:{hidden:false}, + navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }}, + storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}}, +}); +controller.start(); +await controller.reconcile('updates', 3, true); +await controller.reconcile('human-gates', 2, true); +await controller.reconcile('following', 1, true); +await controller.reconcile('human-gates', 0, true); +console.log(JSON.stringify({values, clears})); +""") + + assert result == {"values": [3, 5, 6, 4], "clears": 0} + + def test_app_badge_hides_unsupported_device_control_without_touching_storage(): result = run_badge(""" const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }}; diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index 0d5c9c0..48cda1c 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -34,6 +34,11 @@ const followingControl = { addEventListener:(_name, callback) => state.followingChange = callback, }; const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }}; +const humanGateControl = { + checked:false, disabled:false, + addEventListener:(_name, callback) => state.humanGateChange = callback, +}; +const humanGateStatus = {set textContent(value) { state.humanGateText = value; }, get textContent() { return state.humanGateText; }}; const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback}; const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback}; const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback}; @@ -52,6 +57,7 @@ const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, followingControl, followingStatus, + humanGateControl, humanGateStatus, quietControl, quietStart, quietEnd, quietStatus, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, @@ -119,6 +125,31 @@ def test_device_settings_render_and_wire_the_following_alert_preference(): assert "followingStatus:qs('#push-following-status')" in dashboard +def test_human_gate_alert_toggle_is_opt_in_and_wired_for_touch_settings(): + result = run_scenario(""" +state.server = {available:true,subscribed:true,human_gates_enabled:false,following_enabled:true,public_key:'AQID'}; +state.current = existing; +await feature.init(); +humanGateControl.checked = true; +await state.humanGateChange(); +process.stdout.write(JSON.stringify({requests:state.requests, checked:humanGateControl.checked, following:followingControl.checked, text:state.humanGateText})); +""") + + assert result["checked"] is True + assert result["following"] is True + assert result["requests"][-1][0:2] == ["api/v1/push-subscription/human-gates", "PUT"] + assert json.loads(result["requests"][-1][2]) == {"enabled": True} + assert result["text"] == "Human Gate decision alerts enabled for this device." + + index = INDEX.read_text() + dashboard = DASHBOARD.read_text() + assert 'for="push-human-gates"' in index + assert 'id="push-human-gates" type="checkbox"' in index + assert 'id="push-human-gates-status" role="status" aria-live="polite"' in index + assert "humanGateControl:qs('#push-human-gates')" in dashboard + assert "humanGateStatus:qs('#push-human-gates-status')" in dashboard + + def test_quiet_hours_are_restored_and_saved_as_one_local_schedule(): result = run_scenario(""" state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'}; diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index e16dea2..53e02f0 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -63,6 +63,59 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p assert store.following_preferences("session-b") == {"enabled": False} +@pytest.mark.anyio +async def test_human_gate_dispatch_is_opt_in_private_deduplicated_and_session_bound(tmp_path): + dispatch = getattr(__import__("src.push_notifications", fromlist=["dispatch_human_gate_changes"]), "dispatch_human_gate_changes", None) + assert callable(dispatch), "Human Gate push dispatcher is missing" + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for session_id in ("active", "revoked", "disabled"): + store.upsert(session_id, { + "endpoint": f"https://push.example/{session_id}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_human_gate_preferences("active", enabled=True) + store.set_human_gate_preferences("revoked", enabled=True) + sent = [] + + pending_count = 2 + + async def pending_gates(): + return {"complete": True, "count": pending_count, "items": [{ + "title": "Secret launch", "artifact_url": "https://secret.example/token", + "candidate_hash": "private-hash", + }]} + + async def send(subscription, payload): + sent.append((subscription["endpoint"], json.loads(payload))) + + async def statuses(session_ids): + return {item: ("active" if item == "active" else "revoked") for item in session_ids} + + configuration = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch( + store, configuration, pending_gates, send, session_statuses=statuses + ) == 1 + assert sent == [("https://push.example/active", { + "title": "2 release decisions are waiting", + "body": "Open Human Gates to review them.", + "route": "#/my-work/human-gates", + "tag": "stackchain-human-gates-2", + "human_gate_count": 2, + })] + assert "secret" not in json.dumps(sent).lower() + assert "private-hash" not in json.dumps(sent).lower() + assert await dispatch( + store, configuration, pending_gates, send, session_statuses=statuses + ) == 0 + pending_count = 0 + assert await dispatch( + store, configuration, pending_gates, send, session_statuses=statuses + ) == 0 + assert store.human_gate_notification_devices()[0].delivered_count == 0 + assert len(sent) == 1 + assert store.subscription_for_session("revoked") is None + + def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("session-a", { @@ -233,6 +286,31 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_ assert store.start_day_preferences("session-a")["enabled"] is False +@pytest.mark.anyio +async def test_authenticated_device_controls_human_gate_alerts_independently(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-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_type = getattr(main, "HumanGateNotificationPayload", None) + endpoint = getattr(main, "update_human_gate_notifications", None) + assert payload_type is not None and callable(endpoint), "Human Gate push preference API is missing" + + result = await endpoint(payload_type(enabled=True), request) + + assert result == {"human_gates_enabled": True} + assert (await main.push_status(request))["human_gates_enabled"] is True + assert store.following_preferences("session-a")["enabled"] is False + + @pytest.mark.anyio async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") @@ -699,6 +777,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture) monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch) + monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch) monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch) monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch) @@ -721,8 +800,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m async def no_wait(_seconds): nonlocal sleeps sleeps += 1 - if sleeps <= 4: - if sleeps == 4: + if sleeps <= 5: + if sleeps == 5: first_tick.set() await first_tick.wait() else: @@ -741,16 +820,20 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m async def dispatch_following(*_args, **_kwargs): calls.append("following") + async def dispatch_human_gates(*_args, **_kwargs): + calls.append("human-gates") + monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread) monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following) + monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_human_gates) monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines) monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) with pytest.raises(asyncio.CancelledError): await main._push_poll_loop() - assert sorted(calls) == ["deadline", "following", "start-day", "unread"] + assert sorted(calls) == ["deadline", "following", "human-gates", "start-day", "unread"] @pytest.mark.anyio @@ -776,6 +859,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread) monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day) + monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_start_day) monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines) monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) @@ -802,7 +886,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch): await main._push_poll_loop() - assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0] + assert sorted(intervals) == [30.0, 30.0, 30.0, 600.0, 600.0] @pytest.mark.anyio @@ -1572,6 +1656,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( "start_day_timezone": "UTC", "start_day_reminder_hour": 9, "following_enabled": False, + "human_gates_enabled": False, "quiet_hours_enabled": False, "quiet_hours_start": "22:00", "quiet_hours_end": "07:00", diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 465f310..998a177 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0, 'human-gates':0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -83,7 +83,7 @@ const context = {{ set: async enabled => {{ state.badgeEnabled = enabled; }}, getCounts: async () => ({{...state.badgeCounts}}), setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }}, - clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }}, + clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0, 'human-gates':0}}; }}, }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, @@ -935,6 +935,35 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow(): assert "must-not-render" not in json.dumps(result["notifications"]) +def test_human_gate_push_is_count_only_and_tap_opens_canonical_mobile_review_route(): + result = run_worker_scenario( + """ + state.badgeEnabled = true; + await dispatchPush({ + title:'must-not-render', body:'secret candidate must-not-render', + tag:'stackchain-human-gates-2', route:'#/my-work/human-gates', + human_gate_count:2, artifact_url:'https://secret.example/token', + }); + await dispatchNotificationClick('#/my-work/human-gates', '', null, 'stackchain-human-gates-2'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [{ + "title": "2 release decisions are waiting", + "options": { + "body": "Open Human Gates to review them.", + "tag": "stackchain-human-gates-2", + "data": {"route": "#/my-work/human-gates"}, + }, + }] + assert result["opened"] == [ + "https://forge.example/dashboard/#/my-work/human-gates" + ] + assert result["appBadges"] == [2] + assert "secret" not in json.dumps(result["notifications"]) + + def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route(): result = run_worker_scenario( """ @@ -1023,7 +1052,7 @@ def test_background_badge_combines_following_and_updates_without_channel_overwri ) assert result["appBadges"] == [2, 5, 4] - assert result["badgeCounts"] == {"updates": 3, "following": 1} + assert result["badgeCounts"] == {"updates": 3, "following": 1, "human-gates": 0} assert result["clearedAppBadges"] == 0 @@ -1039,7 +1068,7 @@ def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts(): """ ) - assert result["badgeCounts"] == {"updates": 3, "following": 2} + assert result["badgeCounts"] == {"updates": 3, "following": 2, "human-gates": 0} assert result["appBadges"] == [5] @@ -1054,7 +1083,7 @@ def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push() """ ) - assert result["badgeCounts"] == {"updates": 2, "following": 0} + assert result["badgeCounts"] == {"updates": 2, "following": 0, "human-gates": 0} assert result["appBadges"] == [2, 2] diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py index d60db48..26b63b9 100644 --- a/tests/test_start_day_reminders.py +++ b/tests/test_start_day_reminders.py @@ -209,6 +209,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day( monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch) monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch) + monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch) monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch) monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)