using Inversion; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; namespace ClankerSaddle { /** * Stores the browser's Web Push subscription for the signed-in * user: `endpoint`, `p256dh` and `auth` arrive * from the dashboard bell's subscribe flow * (`pushManager.subscribe` + the VAPID key from the page state). * Validated per {@link PushService.validate_subscription}; the * endpoint is unique per browser, so a re-subscribe replaces the * stored keys. */ public class PushSubscribeAction : StatumAction { private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private PushService push = inject(); private UserService users = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } string endpoint = Forms.field(request, "endpoint"); string p256dh = Forms.field(request, "p256dh"); string auth = Forms.field(request, "auth"); string problem = PushService.validate_subscription(endpoint, p256dh, auth); if (problem != null) { return directives().notify("error", @"Could not enable notifications: $((!)problem)") .navigate("/"); } var me = SpryAuth.current(request); try { yield push.upsert_subscription(db, me.user_id, endpoint, p256dh, auth); return directives().notify("success", "Notifications enabled — you'll be told about questions and reviews") .navigate("/"); } catch (Error e) { return directives().notify("error", @"Could not store the subscription: $(e.message)").navigate("/"); } } } /** * Removes the signed-in user's subscription for an endpoint (the * bell's off switch; the browser already unsubscribed its side). */ public class PushUnsubscribeAction : StatumAction { private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private PushService push = inject(); private UserService users = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } string endpoint = Forms.field(request, "endpoint"); try { var me = SpryAuth.current(request); yield push.remove_subscription(db, me.user_id, endpoint); return directives().notify("success", "Notifications turned off").navigate("/"); } catch (Error e) { return directives().notify("error", @"Could not turn notifications off: $(e.message)").navigate("/"); } } } /** * Flips the signed-in user's per-task "Notify me" pref: opted-in * users receive the task's first start, failure * and completion on top of the global block events every * push-subscribed user gets. */ /** * The bell's "Send test notification" probe (per-browser bell): * queues a REAL Web Push — full RFC 8291 + VAPID path — targeted at * the signed-in user's own subscriptions ({@link * PushService.send_test}), then reports the per-endpoint outcome * through the notify so the UI toasts the truth. This is the * missing end-to-end check: a green light here proves keys, * crypto, the push service and the browser's subscription all * line up, and a failure surfaces its actual reason instead of * silence. Also refreshes the dashboard slot so the bell's * endpoint fingerprints (and a pruned dead endpoint) are current. */ public class PushTestAction : StatumAction { private TaskService task_service = inject(); private SaddleService saddle_service = inject(); private TimelineService timeline_service = inject(); private NoteService note_service = inject(); private InvercargillSql.Orm.OrmSession db = inject(); private PushService push = inject(); private UserService users = inject(); private ModelConfigService model_config = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var me = SpryAuth.current(request); try { var report = yield push.send_test(db, me.user_id); string message; string kind; if (report.failed == 0 && report.pruned == 0) { kind = "success"; message = report.delivered > 1 ? @"Test notification sent to $(report.delivered) browsers" : "Test notification sent"; } else if (report.delivered > 0) { kind = "error"; message = @"Test notification reached $(report.delivered) browser(s)" + @" but $(report.failed) failed"; if (report.first_failure.length > 0) { message += @" ($(report.first_failure))"; } } else { kind = "error"; message = "Test notification failed"; if (report.first_failure.length > 0) { message += @": $(report.first_failure)"; } if (report.pruned > 0) { message += @" — $(report.pruned) dead endpoint(s) removed"; } } var state = yield DashboardState.build(state_service, task_service, saddle_service, timeline_service, db, note_service, action_registry, push, me.user_id, model_config); return directives().notify(kind, message).set(state); } catch (Error e) { return directives().notify("error", @"Could not send the test notification: $(e.message)"); } } } public class ToggleTaskNotifyAction : SealedTaskAction { private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private PushService push = inject(); private UserService users = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } int64 task_id = sealed_task_id(); if (task_id <= 0) { return directives().navigate("/tasks"); } var me = SpryAuth.current(request); try { bool enabled = !(yield push.has_notify_pref(db, me.user_id, task_id)); yield push.set_notify_pref(db, me.user_id, task_id, enabled); return directives().notify("success", enabled ? "You'll be notified when this task starts, fails or completes" : "Task notifications turned off") .navigate(@"/tasks/$(task_id)"); } catch (Error e) { return directives().notify("error", @"Could not toggle notifications: $(e.message)") .navigate(@"/tasks/$(task_id)"); } } } }