feat: notify pending Human Gate decisions (Closes #1437)
This commit is contained in:
parent
ad15f15e3c
commit
7158e00b97
|
|
@ -661,6 +661,7 @@
|
||||||
|
|
||||||
const humanGatesOnChange = (snapshot, state)=>{
|
const humanGatesOnChange = (snapshot, state)=>{
|
||||||
queueCounts.gate = snapshot.pending_count;
|
queueCounts.gate = snapshot.pending_count;
|
||||||
|
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
|
||||||
queueCounts.gateUnavailable = state.available === false;
|
queueCounts.gateUnavailable = state.available === false;
|
||||||
preparationItems.gate = snapshot.items;
|
preparationItems.gate = snapshot.items;
|
||||||
mobileTaskDock.updateQueues(queueCounts);
|
mobileTaskDock.updateQueues(queueCounts);
|
||||||
|
|
@ -8433,6 +8434,8 @@
|
||||||
startDayHour:qs('#push-start-day-hour'),
|
startDayHour:qs('#push-start-day-hour'),
|
||||||
followingControl:qs('#push-following'),
|
followingControl:qs('#push-following'),
|
||||||
followingStatus:qs('#push-following-status'),
|
followingStatus:qs('#push-following-status'),
|
||||||
|
humanGateControl:qs('#push-human-gates'),
|
||||||
|
humanGateStatus:qs('#push-human-gates-status'),
|
||||||
deadlineSnooze:qs('#deadline-snooze'),
|
deadlineSnooze:qs('#deadline-snooze'),
|
||||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,8 @@
|
||||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||||
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
||||||
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
||||||
|
<label class="push-update-control" for="push-human-gates"><input id="push-human-gates" type="checkbox" /> Notify me when release decisions are waiting</label>
|
||||||
|
<span class="small" id="push-human-gates-status" role="status" aria-live="polite"></span>
|
||||||
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
|
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
|
||||||
<div class="push-quiet-hours-times">
|
<div class="push-quiet-hours-times">
|
||||||
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
|
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
}) {
|
}) {
|
||||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
const confirmedCounts = {updates:0, following:0};
|
const confirmedCounts = {updates:0, following:0, 'human-gates':0};
|
||||||
let renderedCount = null;
|
let renderedCount = null;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,7 +39,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render() {
|
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;
|
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||||
try {
|
try {
|
||||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||||
|
|
@ -61,10 +63,12 @@
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||||
|
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
|
||||||
}
|
}
|
||||||
if (!enabled && available()) {
|
if (!enabled && available()) {
|
||||||
confirmedCounts.updates = 0;
|
confirmedCounts.updates = 0;
|
||||||
confirmedCounts.following = 0;
|
confirmedCounts.following = 0;
|
||||||
|
confirmedCounts['human-gates'] = 0;
|
||||||
try {
|
try {
|
||||||
await navigator.clearAppBadge();
|
await navigator.clearAppBadge();
|
||||||
renderedCount = null;
|
renderedCount = null;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||||
startDayControl, startDayStatus, startDayHour,
|
startDayControl, startDayStatus, startDayHour,
|
||||||
followingControl, followingStatus,
|
followingControl, followingStatus,
|
||||||
|
humanGateControl, humanGateStatus,
|
||||||
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
||||||
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
||||||
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
||||||
|
|
@ -135,14 +136,17 @@
|
||||||
if (deadlineControl) deadlineControl.checked = false;
|
if (deadlineControl) deadlineControl.checked = false;
|
||||||
if (startDayControl) startDayControl.checked = false;
|
if (startDayControl) startDayControl.checked = false;
|
||||||
if (followingControl) followingControl.checked = false;
|
if (followingControl) followingControl.checked = false;
|
||||||
|
if (humanGateControl) humanGateControl.checked = false;
|
||||||
configuration.subscribed = false;
|
configuration.subscribed = false;
|
||||||
configuration.deadline_enabled = false;
|
configuration.deadline_enabled = false;
|
||||||
configuration.start_day_enabled = false;
|
configuration.start_day_enabled = false;
|
||||||
configuration.following_enabled = false;
|
configuration.following_enabled = false;
|
||||||
|
configuration.human_gates_enabled = false;
|
||||||
pendingIntent = null;
|
pendingIntent = null;
|
||||||
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.';
|
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||||
if (followingStatus) followingStatus.textContent = 'Following change alerts 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() {
|
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() {
|
async function changeQuietHours() {
|
||||||
if (!quietControl) return false;
|
if (!quietControl) return false;
|
||||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
||||||
|
|
@ -342,7 +378,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recoverPermission(intent = null) {
|
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 (!pendingIntent || notification.permission !== 'granted') return false;
|
||||||
if (recoveryPromise) return recoveryPromise;
|
if (recoveryPromise) return recoveryPromise;
|
||||||
recoveryPromise = (async () => {
|
recoveryPromise = (async () => {
|
||||||
|
|
@ -358,6 +394,10 @@
|
||||||
followingControl.checked = true;
|
followingControl.checked = true;
|
||||||
return changeFollowing();
|
return changeFollowing();
|
||||||
}
|
}
|
||||||
|
if (pendingIntent === 'human-gates') {
|
||||||
|
humanGateControl.checked = true;
|
||||||
|
return changeHumanGates();
|
||||||
|
}
|
||||||
return Boolean(await enable());
|
return Boolean(await enable());
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
|
|
@ -374,6 +414,7 @@
|
||||||
deadlineControl?.addEventListener('change', changeDeadline);
|
deadlineControl?.addEventListener('change', changeDeadline);
|
||||||
startDayControl?.addEventListener('change', changeStartDay);
|
startDayControl?.addEventListener('change', changeStartDay);
|
||||||
followingControl?.addEventListener('change', changeFollowing);
|
followingControl?.addEventListener('change', changeFollowing);
|
||||||
|
humanGateControl?.addEventListener('change', changeHumanGates);
|
||||||
quietControl?.addEventListener('change', changeQuietHours);
|
quietControl?.addEventListener('change', changeQuietHours);
|
||||||
quietStart?.addEventListener('change', changeQuietHours);
|
quietStart?.addEventListener('change', changeQuietHours);
|
||||||
quietEnd?.addEventListener('change', changeQuietHours);
|
quietEnd?.addEventListener('change', changeQuietHours);
|
||||||
|
|
@ -384,6 +425,7 @@
|
||||||
if (deadlineControl) deadlineControl.disabled = true;
|
if (deadlineControl) deadlineControl.disabled = true;
|
||||||
if (startDayControl) startDayControl.disabled = true;
|
if (startDayControl) startDayControl.disabled = true;
|
||||||
if (followingControl) followingControl.disabled = true;
|
if (followingControl) followingControl.disabled = true;
|
||||||
|
if (humanGateControl) humanGateControl.disabled = true;
|
||||||
if (quietControl) quietControl.disabled = true;
|
if (quietControl) quietControl.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;
|
||||||
|
|
@ -392,6 +434,7 @@
|
||||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||||
if (followingControl) followingControl.checked = Boolean(configuration.following_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 (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
|
||||||
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
||||||
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
||||||
|
|
@ -408,11 +451,14 @@
|
||||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||||
? 'Following change alerts enabled for this device.'
|
? 'Following change alerts enabled for this device.'
|
||||||
: 'Following change alerts are off 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
|
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
|
||||||
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
||||||
: 'Routine alert quiet hours are off for this device.';
|
: 'Routine alert quiet hours are off for this device.';
|
||||||
renderDeadlineSnooze();
|
renderDeadlineSnooze();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ function createAppBadgePreference() {
|
||||||
async getCounts() {
|
async getCounts() {
|
||||||
const database = await open();
|
const database = await open();
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const channel of ['updates', 'following']) {
|
for (const channel of ['updates', 'following', 'human-gates']) {
|
||||||
counts[channel] = await new Promise((resolve, reject) => {
|
counts[channel] = await new Promise((resolve, reject) => {
|
||||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||||
|
|
@ -67,6 +67,7 @@ function createAppBadgePreference() {
|
||||||
async clearCounts() {
|
async clearCounts() {
|
||||||
await this.setCount('updates', 0);
|
await this.setCount('updates', 0);
|
||||||
await this.setCount('following', 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;
|
let renderedBackgroundBadgeCount = null;
|
||||||
|
|
||||||
async function reconcileBackgroundAppBadge(channel, count) {
|
async function reconcileBackgroundAppBadge(channel, count) {
|
||||||
if (!['updates', 'following'].includes(channel)
|
if (!['updates', 'following', 'human-gates'].includes(channel)
|
||||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||||
|| typeof self.registration.setAppBadge !== 'function'
|
|| typeof self.registration.setAppBadge !== 'function'
|
||||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||||
|
|
@ -84,7 +85,7 @@ async function reconcileBackgroundAppBadge(channel, count) {
|
||||||
try {
|
try {
|
||||||
await appBadgePreference.setCount(channel, count);
|
await appBadgePreference.setCount(channel, count);
|
||||||
const counts = await appBadgePreference.getCounts();
|
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 (renderedBackgroundBadgeCount === total) return false;
|
||||||
if (total > 0) await self.registration.setAppBadge(total);
|
if (total > 0) await self.registration.setAppBadge(total);
|
||||||
else await self.registration.clearAppBadge();
|
else await self.registration.clearAppBadge();
|
||||||
|
|
@ -623,7 +624,7 @@ self.addEventListener('message', event => {
|
||||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||||
const channel = event.data.channel;
|
const channel = event.data.channel;
|
||||||
const count = event.data.count;
|
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;
|
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||||
try {
|
try {
|
||||||
if (await appBadgePreference.get()) {
|
if (await appBadgePreference.get()) {
|
||||||
|
|
@ -708,6 +709,7 @@ self.addEventListener('push', event => {
|
||||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||||
const deadlineCount = Number(payload.deadline_count);
|
const deadlineCount = Number(payload.deadline_count);
|
||||||
const followingCount = Number(payload.following_count);
|
const followingCount = Number(payload.following_count);
|
||||||
|
const humanGateCount = Number(payload.human_gate_count);
|
||||||
const planDate = String(payload.plan_date || '');
|
const planDate = String(payload.plan_date || '');
|
||||||
if (
|
if (
|
||||||
route === '#/my-work/start-day'
|
route === '#/my-work/start-day'
|
||||||
|
|
@ -744,6 +746,25 @@ self.addEventListener('push', event => {
|
||||||
));
|
));
|
||||||
return;
|
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 (
|
if (
|
||||||
route === '#/my-work/following'
|
route === '#/my-work/following'
|
||||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||||
|
|
|
||||||
52
src/main.py
52
src/main.py
|
|
@ -62,6 +62,7 @@ from src.push_notifications import (
|
||||||
PushConfiguration,
|
PushConfiguration,
|
||||||
dispatch_deadline_reminders,
|
dispatch_deadline_reminders,
|
||||||
dispatch_following_changes,
|
dispatch_following_changes,
|
||||||
|
dispatch_human_gate_changes,
|
||||||
dispatch_start_day_reminders,
|
dispatch_start_day_reminders,
|
||||||
dispatch_unread_updates,
|
dispatch_unread_updates,
|
||||||
send_web_push,
|
send_web_push,
|
||||||
|
|
@ -142,6 +143,18 @@ async def _following_push_snapshot() -> dict:
|
||||||
return await get_following(Response())
|
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(
|
async def _identity_bound_push_session_statuses(
|
||||||
management_ids: list[str],
|
management_ids: list[str],
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
|
|
@ -207,6 +220,17 @@ async def _push_poll_loop() -> None:
|
||||||
max_concurrency=max_concurrency,
|
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:
|
async def dispatch_start_day() -> None:
|
||||||
await dispatch_start_day_reminders(
|
await dispatch_start_day_reminders(
|
||||||
_push_subscription_store,
|
_push_subscription_store,
|
||||||
|
|
@ -221,6 +245,7 @@ async def _push_poll_loop() -> None:
|
||||||
channel_tasks = (
|
channel_tasks = (
|
||||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
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_following, interval=interval)),
|
||||||
|
asyncio.create_task(_push_channel_loop(dispatch_human_gates, interval=interval)),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||||
),
|
),
|
||||||
|
|
@ -537,6 +562,10 @@ class FollowingNotificationPayload(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class HumanGateNotificationPayload(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
class QuietHoursPayload(BaseModel):
|
class QuietHoursPayload(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
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(
|
following_preferences = await asyncio.to_thread(
|
||||||
_push_subscription_store.following_preferences, device_id
|
_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(
|
quiet_hours = await asyncio.to_thread(
|
||||||
_push_subscription_store.quiet_hours, device_id
|
_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_timezone": start_day_preferences["timezone"],
|
||||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||||
"following_enabled": following_preferences["enabled"],
|
"following_enabled": following_preferences["enabled"],
|
||||||
|
"human_gates_enabled": human_gate_preferences["enabled"],
|
||||||
"quiet_hours_enabled": quiet_hours["enabled"],
|
"quiet_hours_enabled": quiet_hours["enabled"],
|
||||||
"quiet_hours_start": quiet_hours["start"],
|
"quiet_hours_start": quiet_hours["start"],
|
||||||
"quiet_hours_end": quiet_hours["end"],
|
"quiet_hours_end": quiet_hours["end"],
|
||||||
|
|
@ -2902,6 +2935,25 @@ async def update_following_notifications(
|
||||||
return {"following_enabled": payload.enabled}
|
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")
|
@app.put("/api/v1/push-subscription/quiet-hours")
|
||||||
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
|
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
|
||||||
device_id = await dashboard_auth.session_management_id(
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,104 @@ async def dispatch_following_changes(
|
||||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
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(
|
async def dispatch_unread_updates(
|
||||||
store: PushSubscriptionStore,
|
store: PushSubscriptionStore,
|
||||||
configuration: PushConfiguration,
|
configuration: PushConfiguration,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,14 @@ class FollowingNotificationDevice:
|
||||||
catch_up: bool = False
|
catch_up: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HumanGateNotificationDevice:
|
||||||
|
session_id: str
|
||||||
|
subscription: dict
|
||||||
|
delivered_count: int
|
||||||
|
catch_up: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DisabledPushSubscriptionStore:
|
class DisabledPushSubscriptionStore:
|
||||||
"""No-persistence store used when Web Push is not configured."""
|
"""No-persistence store used when Web Push is not configured."""
|
||||||
|
|
||||||
|
|
@ -95,12 +103,18 @@ class DisabledPushSubscriptionStore:
|
||||||
def following_preferences(self, session_id: str) -> dict:
|
def following_preferences(self, session_id: str) -> dict:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
|
def human_gate_preferences(self, session_id: str) -> dict:
|
||||||
|
return {"enabled": False}
|
||||||
|
|
||||||
def quiet_hours(self, session_id: str) -> dict:
|
def quiet_hours(self, session_id: str) -> dict:
|
||||||
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
||||||
|
|
||||||
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
||||||
return []
|
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]:
|
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -140,12 +154,18 @@ class DisabledPushSubscriptionStore:
|
||||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def set_human_gate_preferences(self, *args, **kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def set_quiet_hours(self, *args, **kwargs) -> None:
|
def set_quiet_hours(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -282,6 +302,13 @@ 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_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 (
|
CREATE TABLE IF NOT EXISTS push_quiet_hours (
|
||||||
session_id TEXT PRIMARY KEY,
|
session_id TEXT PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -642,6 +669,23 @@ class PushSubscriptionStore:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return {"enabled": bool(row[0]) if row else False}
|
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(
|
def set_quiet_hours(
|
||||||
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -713,6 +757,46 @@ class PushSubscriptionStore:
|
||||||
(session_id,),
|
(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(
|
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]],
|
||||||
*, now: float | None = None,
|
*, now: float | None = None,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,27 @@ console.log(JSON.stringify({values, clears}));
|
||||||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
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():
|
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||||
result = run_badge("""
|
result = run_badge("""
|
||||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ const followingControl = {
|
||||||
addEventListener:(_name, callback) => state.followingChange = callback,
|
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||||
};
|
};
|
||||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
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 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 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};
|
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,
|
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||||
startDayControl, startDayStatus, startDayHour,
|
startDayControl, startDayStatus, startDayHour,
|
||||||
followingControl, followingStatus,
|
followingControl, followingStatus,
|
||||||
|
humanGateControl, humanGateStatus,
|
||||||
quietControl, quietStart, quietEnd, quietStatus,
|
quietControl, quietStart, quietEnd, quietStatus,
|
||||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||||
onReviewDeadlines:() => { state.reviewed = true; },
|
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
|
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():
|
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
|
||||||
result = run_scenario("""
|
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'};
|
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'};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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):
|
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
store.upsert("session-a", {
|
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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
|
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
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.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||||
monkeypatch.setattr(main, "dispatch_following_changes", 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_deadline_reminders", hold_dispatch)
|
||||||
monkeypatch.setattr(main, "dispatch_start_day_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):
|
async def no_wait(_seconds):
|
||||||
nonlocal sleeps
|
nonlocal sleeps
|
||||||
sleeps += 1
|
sleeps += 1
|
||||||
if sleeps <= 4:
|
if sleeps <= 5:
|
||||||
if sleeps == 4:
|
if sleeps == 5:
|
||||||
first_tick.set()
|
first_tick.set()
|
||||||
await first_tick.wait()
|
await first_tick.wait()
|
||||||
else:
|
else:
|
||||||
|
|
@ -741,16 +820,20 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||||
async def dispatch_following(*_args, **_kwargs):
|
async def dispatch_following(*_args, **_kwargs):
|
||||||
calls.append("following")
|
calls.append("following")
|
||||||
|
|
||||||
|
async def dispatch_human_gates(*_args, **_kwargs):
|
||||||
|
calls.append("human-gates")
|
||||||
|
|
||||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
|
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_deadline_reminders", dispatch_deadlines)
|
||||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await main._push_poll_loop()
|
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
|
@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.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
|
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_deadline_reminders", dispatch_deadlines)
|
||||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
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()
|
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
|
@pytest.mark.anyio
|
||||||
|
|
@ -1572,6 +1656,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
"start_day_timezone": "UTC",
|
"start_day_timezone": "UTC",
|
||||||
"start_day_reminder_hour": 9,
|
"start_day_reminder_hour": 9,
|
||||||
"following_enabled": False,
|
"following_enabled": False,
|
||||||
|
"human_gates_enabled": False,
|
||||||
"quiet_hours_enabled": False,
|
"quiet_hours_enabled": False,
|
||||||
"quiet_hours_start": "22:00",
|
"quiet_hours_start": "22:00",
|
||||||
"quiet_hours_end": "07:00",
|
"quiet_hours_end": "07:00",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
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();
|
const storedResponses = new Map();
|
||||||
storedResponses.set(
|
storedResponses.set(
|
||||||
'https://forge.example/dashboard/__offline-session-lease',
|
'https://forge.example/dashboard/__offline-session-lease',
|
||||||
|
|
@ -83,7 +83,7 @@ const context = {{
|
||||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||||
getCounts: async () => ({{...state.badgeCounts}}),
|
getCounts: async () => ({{...state.badgeCounts}}),
|
||||||
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
|
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; }},
|
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||||
skipWaiting: async () => {{ state.skipped = true; }},
|
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"])
|
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():
|
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
|
||||||
result = run_worker_scenario(
|
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["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
|
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]
|
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]
|
assert result["appBadges"] == [2, 2]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
||||||
monkeypatch.setattr(main, "dispatch_following_changes", 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_deadline_reminders", hold_dispatch)
|
||||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user