Compare commits
No commits in common. "92b0fd56ca065dea4650412835674865a2af0bd1" and "f4a4ed84c370271691bb40918679d5cf7becbeb9" have entirely different histories.
92b0fd56ca
...
f4a4ed84c3
|
|
@ -188,9 +188,7 @@ export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts
|
||||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||||
# The feature stays disabled unless all three values are present. Privacy-safe update
|
# The feature stays disabled unless all three values are present.
|
||||||
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
|
||||||
# 09:00 in the device's local timezone without opening the dashboard.
|
|
||||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||||
|
|
|
||||||
|
|
@ -289,10 +289,7 @@ self.addEventListener('push', event => {
|
||||||
&& route === '#/my-work/update/' + notificationId
|
&& route === '#/my-work/update/' + notificationId
|
||||||
&& tag === 'stackchain-update-' + notificationId
|
&& tag === 'stackchain-update-' + notificationId
|
||||||
) {
|
) {
|
||||||
options.actions = [
|
options.actions = [{ action: 'mark-read', title: 'Mark read' }];
|
||||||
{ action: 'mark-read', title: 'Mark read' },
|
|
||||||
{ action: 'tomorrow', title: 'Tomorrow' },
|
|
||||||
];
|
|
||||||
options.data.notificationId = notificationId;
|
options.data.notificationId = notificationId;
|
||||||
}
|
}
|
||||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||||
|
|
@ -309,36 +306,6 @@ async function openWorkRoute(route) {
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
self.addEventListener('notificationclick', event => {
|
||||||
const route = String(event.notification.data?.route || '');
|
const route = String(event.notification.data?.route || '');
|
||||||
if (event.action === 'tomorrow') {
|
|
||||||
const notificationId = Number(event.notification.data?.notificationId);
|
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
|
||||||
|| route !== '#/my-work/update/' + notificationId
|
|
||||||
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
|
||||||
) return;
|
|
||||||
event.waitUntil((async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
|
||||||
wake.setDate(wake.getDate() + 1);
|
|
||||||
wake.setHours(9, 0, 0, 0);
|
|
||||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
event.notification.close();
|
|
||||||
} catch (_error) {
|
|
||||||
await openWorkRoute(route);
|
|
||||||
event.notification.close();
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
})());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.action === 'mark-read') {
|
if (event.action === 'mark-read') {
|
||||||
const notificationId = Number(event.notification.data?.notificationId);
|
const notificationId = Number(event.notification.data?.notificationId);
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
55
src/main.py
55
src/main.py
|
|
@ -426,10 +426,6 @@ class LaterOperationBatch(BaseModel):
|
||||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class NotificationLaterRequest(BaseModel):
|
|
||||||
wake_at: str = Field(min_length=1, max_length=100)
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationReply(BaseModel):
|
class NotificationReply(BaseModel):
|
||||||
body: str = Field(min_length=1, max_length=10_000)
|
body: str = Field(min_length=1, max_length=10_000)
|
||||||
|
|
||||||
|
|
@ -3220,57 +3216,6 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
return JSONResponse({"id": thread_id, "status": "read"})
|
return JSONResponse({"id": thread_id, "status": "read"})
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
|
||||||
async def defer_notification(
|
|
||||||
payload: NotificationLaterRequest,
|
|
||||||
thread_id: int = PathParam(gt=0),
|
|
||||||
) -> JSONResponse:
|
|
||||||
login = await _confirmed_login()
|
|
||||||
try:
|
|
||||||
item = await asyncio.wait_for(
|
|
||||||
gitea_proxy.resolve_work_route("update", None, None, thread_id),
|
|
||||||
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
repository = item.get("repository")
|
|
||||||
if not isinstance(repository, str) or not repository:
|
|
||||||
raise ValueError("The update has no repository identity")
|
|
||||||
item_id = f"update:{repository}::{thread_id}"
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
_later_store().apply,
|
|
||||||
login,
|
|
||||||
f"push-tomorrow:{thread_id}:{payload.wake_at}",
|
|
||||||
"defer",
|
|
||||||
item_id,
|
|
||||||
wake_at=payload.wake_at,
|
|
||||||
)
|
|
||||||
except gitea_proxy.WorkRouteUnavailableError:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "This update is no longer available to defer."},
|
|
||||||
status_code=409,
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
raise HTTPException(status_code=422, detail=str(error))
|
|
||||||
except TimeoutError:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Deferring the update timed out. Please retry."},
|
|
||||||
status_code=503,
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
except (OSError, sqlite3.Error):
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Later synchronization is unavailable. Please retry."},
|
|
||||||
status_code=503,
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
return JSONResponse({
|
|
||||||
"id": thread_id,
|
|
||||||
"status": "deferred",
|
|
||||||
"item_id": item_id,
|
|
||||||
"wake_at": payload.wake_at,
|
|
||||||
"revision": result["revision"],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
|
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
|
||||||
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src import main
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_push_tomorrow_resolves_unread_update_and_persists_canonical_later_item(
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
resolved = []
|
|
||||||
applied = []
|
|
||||||
|
|
||||||
async def confirmed_login():
|
|
||||||
return "timmy"
|
|
||||||
|
|
||||||
async def resolve(kind, repository, number, notification_id):
|
|
||||||
resolved.append((kind, repository, number, notification_id))
|
|
||||||
return {
|
|
||||||
"kind": "update",
|
|
||||||
"notification_id": 42,
|
|
||||||
"repository": "stackchain/api",
|
|
||||||
"title": "Retry failed deploy",
|
|
||||||
}
|
|
||||||
|
|
||||||
class Store:
|
|
||||||
def apply(
|
|
||||||
self,
|
|
||||||
login,
|
|
||||||
operation_id,
|
|
||||||
action,
|
|
||||||
item_id,
|
|
||||||
*,
|
|
||||||
wake_at=None,
|
|
||||||
base_revision=None,
|
|
||||||
):
|
|
||||||
applied.append(
|
|
||||||
(login, operation_id, action, item_id, wake_at, base_revision)
|
|
||||||
)
|
|
||||||
return {"revision": 3, "records": {item_id: wake_at}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "_confirmed_login", confirmed_login)
|
|
||||||
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
|
|
||||||
monkeypatch.setattr(main, "_later_store", lambda: Store())
|
|
||||||
|
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
||||||
response = await client.patch(
|
|
||||||
"/api/v1/notifications/42/later",
|
|
||||||
json={"wake_at": "2026-08-12T09:00:00.000Z"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["cache-control"] == "no-store"
|
|
||||||
assert response.json() == {
|
|
||||||
"id": 42,
|
|
||||||
"status": "deferred",
|
|
||||||
"item_id": "update:stackchain/api::42",
|
|
||||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
|
||||||
"revision": 3,
|
|
||||||
}
|
|
||||||
assert resolved == [("update", None, None, 42)]
|
|
||||||
assert applied == [(
|
|
||||||
"timmy",
|
|
||||||
"push-tomorrow:42:2026-08-12T09:00:00.000Z",
|
|
||||||
"defer",
|
|
||||||
"update:stackchain/api::42",
|
|
||||||
"2026-08-12T09:00:00.000Z",
|
|
||||||
None,
|
|
||||||
)]
|
|
||||||
|
|
@ -105,11 +105,11 @@ async function dispatchMessage(data, ports = []) {{
|
||||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
}}
|
}}
|
||||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
|
async function dispatchNotificationClick(route, action = '', notificationId = null) {{
|
||||||
let pending;
|
let pending;
|
||||||
listeners.notificationclick({{
|
listeners.notificationclick({{
|
||||||
action,
|
action,
|
||||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
notification: {{data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||||
waitUntil: promise => {{ pending = promise; }},
|
waitUntil: promise => {{ pending = promise; }},
|
||||||
}});
|
}});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
|
|
@ -401,10 +401,7 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||||
"options": {
|
"options": {
|
||||||
"body": "Tap to review it in Stackchain.",
|
"body": "Tap to review it in Stackchain.",
|
||||||
"tag": "stackchain-update-42",
|
"tag": "stackchain-update-42",
|
||||||
"actions": [
|
"actions": [{"action": "mark-read", "title": "Mark read"}],
|
||||||
{"action": "mark-read", "title": "Mark read"},
|
|
||||||
{"action": "tomorrow", "title": "Tomorrow"},
|
|
||||||
],
|
|
||||||
"data": {
|
"data": {
|
||||||
"route": "#/my-work/update/42",
|
"route": "#/my-work/update/42",
|
||||||
"notificationId": 42,
|
"notificationId": 42,
|
||||||
|
|
@ -456,51 +453,6 @@ def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_a
|
||||||
assert result["state"]["focused"] == []
|
assert result["state"]["focused"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_push_tomorrow_action_defers_privately_without_opening_or_marking_read():
|
|
||||||
result = run_worker_scenario(
|
|
||||||
"""
|
|
||||||
const calls = [];
|
|
||||||
context.self.__STACKCHAIN_NOW = () => new Date('2026-08-11T18:30:00.000Z');
|
|
||||||
context.fetch = async (request, options = {}) => {
|
|
||||||
const url = String(request.url || request);
|
|
||||||
const headers = new Headers(options.headers || {});
|
|
||||||
calls.push({
|
|
||||||
url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token'),
|
|
||||||
body:options.body ? JSON.parse(options.body) : null,
|
|
||||||
});
|
|
||||||
if (url.endsWith('/api/v1/session')) {
|
|
||||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
||||||
status:200, headers:{'Content-Type':'application/json'},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({id:42,status:'deferred'}), {
|
|
||||||
status:200, headers:{'Content-Type':'application/json'},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
await dispatchNotificationClick('#/my-work/update/42', 'tomorrow', 42, 'stackchain-update-42');
|
|
||||||
process.stdout.write(JSON.stringify({state,calls}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["calls"] == [
|
|
||||||
{
|
|
||||||
"url": "https://forge.example/dashboard/api/v1/session",
|
|
||||||
"method": "GET",
|
|
||||||
"csrf": None,
|
|
||||||
"body": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://forge.example/dashboard/api/v1/notifications/42/later",
|
|
||||||
"method": "PATCH",
|
|
||||||
"csrf": "session-proof",
|
|
||||||
"body": {"wake_at": "2026-08-12T09:00:00.000Z"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
assert result["state"]["notificationClosed"] is True
|
|
||||||
assert result["state"]["opened"] == []
|
|
||||||
assert all(not call["url"].endswith("/read") for call in result["calls"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_push_mark_read_failure_opens_existing_update_reader():
|
def test_push_mark_read_failure_opens_existing_update_reader():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user