feat: push notifications on each device (Web Push)

- What reaches the bell also reaches the devices where the person
  turned «Avisos en este dispositivo» on, with TaskStream closed: the
  Notifier sends each new notification encrypted (aes128gcm) and signed
  with VAPID, and forgets subscriptions the push service dropped
  (404/410). A failure never blocks the event stream.
- The API keeps one subscription per device (push_subscriptions) and
  exposes the VAPID public key in the notification settings; GraphQL
  subscribePush/unsubscribePush, REST /me/push-subscriptions. Without
  VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY push is off.
- The web registers /push-sw.js, a service worker that only shows the
  notification and opens its ticket on a tap (no caching, served
  without cache by nginx), and a manifest that makes it installable,
  which iPhones need for push.
- The bell's toggles show their text next to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 08:38:07 +02:00
parent 5ee627e689
commit 233c46e9fa
50 changed files with 1577 additions and 53 deletions

View File

@@ -1112,6 +1112,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/push-subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Push on this device (a browser's Web Push subscription) */
post: operations["NotificationsController_subscribePush"];
/** Push off on this device */
delete: operations["NotificationsController_unsubscribePush"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/filters": {
parameters: {
query?: never;
@@ -2531,11 +2549,25 @@ export interface components {
email: boolean;
/** @description Whether this installation can send e-mail */
emailAvailable: boolean;
/** @description VAPID public key to subscribe a device to push with; null: no push here */
pushPublicKey: string | null;
};
NotificationSettingsDto: {
/** @description Copies by e-mail */
email: boolean;
};
PushSubscriptionDto: {
/** @description The push service URL (PushSubscription.endpoint) */
endpoint: string;
/** @description keys.p256dh of the subscription */
p256dh: string;
/** @description keys.auth of the subscription */
auth: string;
};
PushEndpointDto: {
/** @description The endpoint of the device to turn off */
endpoint: string;
};
SavedFilterCriteriaResponseDto: {
types?: string[];
areas?: string[];
@@ -7792,6 +7824,102 @@ export interface operations {
};
};
};
NotificationsController_subscribePush: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PushSubscriptionDto"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
NotificationsController_unsubscribePush: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PushEndpointDto"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SavedFiltersController_list: {
parameters: {
query?: never;