diff --git a/.env.example b/.env.example index aa60980..9fe0cdf 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ SMTP_URL= MAIL_FROM= WEB_URL=http://localhost:4200 +# Optional push notifications (Web Push). Without both keys nothing is sent. +# Make them once with `npx web-push generate-vapid-keys` (changing them drops +# every device's subscription). VAPID_SUBJECT: a mailto: or https: contact. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:no-reply@pireon.dev + # Outgoing webhooks refuse hosts on this machine or its private network unless true. WEBHOOKS_ALLOW_PRIVATE=false diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 07b8e65..1036596 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7260,6 +7260,130 @@ ] } }, + "/api/me/push-subscriptions": { + "post": { + "operationId": "NotificationsController_subscribePush", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushSubscriptionDto" + } + } + } + }, + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Push on this device (a browser's Web Push subscription)", + "tags": [ + "Users" + ] + }, + "delete": { + "operationId": "NotificationsController_unsubscribePush", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushEndpointDto" + } + } + } + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Push off on this device", + "tags": [ + "Users" + ] + } + }, "/api/projects/{project}/filters": { "get": { "operationId": "SavedFiltersController_list", @@ -12933,11 +13057,17 @@ "emailAvailable": { "type": "boolean", "description": "Whether this installation can send e-mail" + }, + "pushPublicKey": { + "type": "string", + "nullable": true, + "description": "VAPID public key to subscribe a device to push with; null: no push here" } }, "required": [ "email", - "emailAvailable" + "emailAvailable", + "pushPublicKey" ] }, "NotificationSettingsDto": { @@ -12952,6 +13082,40 @@ "email" ] }, + "PushSubscriptionDto": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "description": "The push service URL (PushSubscription.endpoint)" + }, + "p256dh": { + "type": "string", + "description": "keys.p256dh of the subscription" + }, + "auth": { + "type": "string", + "description": "keys.auth of the subscription" + } + }, + "required": [ + "endpoint", + "p256dh", + "auth" + ] + }, + "PushEndpointDto": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "description": "The endpoint of the device to turn off" + } + }, + "required": [ + "endpoint" + ] + }, "SavedFilterCriteriaResponseDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 435787f..57f63f9 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -772,6 +772,9 @@ type Mutation { """Start a planned sprint (only one under way per project)""" startSprint(sprintId: ID!): CommandResult! + """Push on this device""" + subscribePush(subscription: PushSubscriptionInput!): Boolean! + """Send a ping now and tell how it went""" testWebhook(webhookId: ID!): WebhookDelivery! @@ -781,6 +784,9 @@ type Mutation { trashIssue(issueId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! + """Push off on this device""" + unsubscribePush(endpoint: String!): Boolean! + """Stop following a ticket""" unwatchIssue(issueId: ID!): CommandResult! @@ -859,6 +865,11 @@ type NotificationSettings { """Whether this installation can send e-mail at all""" emailAvailable: Boolean! + + """ + VAPID public key to subscribe a device to push with; null: no push here + """ + pushPublicKey: String } type Project { @@ -980,6 +991,13 @@ type ProjectStats { total: Int! } +"""A browser's Web Push subscription (PushSubscription.toJSON())""" +input PushSubscriptionInput { + auth: String! + endpoint: String! + p256dh: String! +} + type QaStats { failed: Int! failureRate: Float diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index 3b8e0bc..eb0bf59 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -52,6 +52,11 @@ export const configSchema = Joi.object({ // E-mail copies of notifications (optional): without SMTP_URL nothing is sent. SMTP_URL: Joi.string().allow('').optional(), MAIL_FROM: Joi.string().allow('').optional(), + // Push notifications (optional): without both VAPID keys nothing is sent. + // Make them once with `npx web-push generate-vapid-keys`. + VAPID_PUBLIC_KEY: Joi.string().allow('').optional(), + VAPID_PRIVATE_KEY: Joi.string().allow('').optional(), + VAPID_SUBJECT: Joi.string().allow('').optional(), // Public address of the web app, for links in e-mails. WEB_URL: Joi.string().allow('').optional(), }); diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index fe282a2..c3d32d8 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -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; diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index ffdcdbc..a67ca24 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -28,6 +28,19 @@ server { proxy_read_timeout 1h; } + # The service worker and the manifest have no hash in their name: never + # cached, or browsers would keep an old one (exact matches win over the + # regex below). + location = /push-sw.js { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache"; + default_type application/manifest+json; + try_files $uri =404; + } + # Hashed bundles can be cached forever; index.html never. location ~* \.(js|css|woff2?|svg|png|ico)$ { expires 1y; diff --git a/apps/web/public/logo-192.png b/apps/web/public/logo-192.png new file mode 100644 index 0000000..084b65e Binary files /dev/null and b/apps/web/public/logo-192.png differ diff --git a/apps/web/public/logo-512.png b/apps/web/public/logo-512.png new file mode 100644 index 0000000..5e47702 Binary files /dev/null and b/apps/web/public/logo-512.png differ diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..4a5197f --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "TaskStream", + "short_name": "TaskStream", + "description": "Gestión de proyectos y tickets en tiempo real", + "lang": "es", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f7fa", + "theme_color": "#a347e1", + "icons": [ + { + "src": "/logo-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/logo-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/apps/web/public/push-sw.js b/apps/web/public/push-sw.js new file mode 100644 index 0000000..b27c19e --- /dev/null +++ b/apps/web/public/push-sw.js @@ -0,0 +1,52 @@ +/* + * TaskStream's service worker: push notifications only. It caches nothing, + * so a new deploy never leaves an old version of the app behind. + * + * The API sends `{ title, body, url, tag }` (see Notifier's payloadOf). + */ +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => + event.waitUntil(self.clients.claim()), +); + +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { body: event.data ? event.data.text() : '' }; + } + event.waitUntil( + self.registration.showNotification(data.title || 'TaskStream', { + body: data.body || '', + tag: data.tag, + icon: '/logo-192.png', + badge: '/logo-96.png', + data: { url: data.url || '/' }, + }), + ); +}); + +// A tap opens the ticket: in a TaskStream tab already open, or in a new one. +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = new URL( + event.notification.data?.url || '/', + self.location.origin, + ).href; + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((tabs) => { + for (const tab of tabs) { + if ( + new URL(tab.url).origin === self.location.origin && + 'focus' in tab + ) { + return tab.focus().then(() => tab.navigate(target)); + } + } + return self.clients.openWindow(target); + }), + ); +}); diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 0761aee..609f8f9 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -12,6 +12,8 @@ + + diff --git a/docker-compose.yml b/docker-compose.yml index ae3c21d..b92446f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,10 @@ services: SMTP_URL: ${SMTP_URL:-} MAIL_FROM: ${MAIL_FROM:-} WEB_URL: ${WEB_URL:-} + # Push notifications (optional): `npx web-push generate-vapid-keys`. + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} + VAPID_SUBJECT: ${VAPID_SUBJECT:-} # Outgoing webhooks to private addresses (off: they could reach internal services). WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false} # Due-date reminders, checked every hour. diff --git a/docs/03-api.md b/docs/03-api.md index 9da730d..d9e9aff 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -286,6 +286,13 @@ ningún agregado, solo cada uno lee y descarta las suyas. | Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` | | Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` | +**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la +persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública +VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción +(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va +cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la +suscripción se olvida. Como el correo, un fallo de envío no frena los eventos. + **Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en `REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder::::`, así que no se repite aunque diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index d71d756..8c0a0f2 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -127,6 +127,15 @@ hijas de `/p/:key` reciben la misma `ProjectFacade`. Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL. +### Notificaciones push + +En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra +`/push-sw.js`, pide permiso, suscribe el navegador y manda la suscripción a la API (`PushFacade` + el puerto `PushDevice`, +con `BrowserPushDevice` como adaptador). El _service worker_ solo hace push: muestra el aviso y, al pulsarlo, enfoca una +pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una +versión vieja; nginx sirve `push-sw.js` y `manifest.webmanifest` sin caché. Con el manifiesto la web se puede instalar, +que en iPhone es condición para recibir push (iOS 16.4+). + ### Calendario «Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 0229449..443abb3 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -70,39 +70,41 @@ donde los lee Compose. ## 7.2 Variables de entorno de la API -| Variable | Por defecto | Descripción | -| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `3000` | Puerto HTTP | -| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces | -| `POSTGRES_URL` | — (obligatoria) | Event store y lookups | -| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones | -| `MONGO_URL` | — (obligatoria) | Read model | -| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché | -| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream | -| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream | -| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) | -| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad | -| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI | -| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña | -| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | -| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | -| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | -| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente | -| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | -| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | -| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | -| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | -| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | -| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | -| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` | -| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada | -| `MAIL_FROM` | `TaskStream ` | Remitente de esos correos | -| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos | -| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | -| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | -| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido | -| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios | -| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo | +| Variable | Por defecto | Descripción | +| ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `3000` | Puerto HTTP | +| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces | +| `POSTGRES_URL` | — (obligatoria) | Event store y lookups | +| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones | +| `MONGO_URL` | — (obligatoria) | Read model | +| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché | +| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream | +| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream | +| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) | +| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad | +| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI | +| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña | +| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | +| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | +| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | +| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente | +| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | +| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | +| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | +| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | +| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | +| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | +| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` | +| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada | +| `MAIL_FROM` | `TaskStream ` | Remitente de esos correos | +| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones | +| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal | +| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos | +| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | +| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | +| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido | +| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios | +| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 23efe13..8b22c34 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -32,11 +32,7 @@ cuando hay pistas, por dónde empezar. En este orden: -1. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté - cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de - notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos - variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+). -2. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de +1. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista. ### Valoradas para después @@ -103,3 +99,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente. - Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta renovable. +- Notificaciones push (Web Push) en cada dispositivo, con la web instalable. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index a9b4919..0597c32 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index a10b480..41e8e9c 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/notifications.service.ts b/libs/api/tracker/application/src/lib/notifications.service.ts index d55a105..6aa6110 100644 --- a/libs/api/tracker/application/src/lib/notifications.service.ts +++ b/libs/api/tracker/application/src/lib/notifications.service.ts @@ -4,6 +4,8 @@ import { NotificationReadModel } from '@task-stream/api/tracker/domain'; import { SendDueRemindersCommand } from './notifications/reminders'; import { MarkNotificationsReadCommand, + SubscribePushCommand, + UnsubscribePushCommand, UpdateNotificationSettingsCommand, } from './notifications/notifications.commands'; import { @@ -37,6 +39,24 @@ export class NotificationsService { } /** `ids` omitted: everything. */ + /** Push on this device (a browser's Web Push subscription). */ + subscribePush( + userId: string, + endpoint: string, + keys: { p256dh: string; auth: string }, + ): Promise { + return this.commandBus.execute( + new SubscribePushCommand(userId, endpoint, keys), + ); + } + + /** Push off on this device. */ + unsubscribePush(userId: string, endpoint: string): Promise { + return this.commandBus.execute( + new UnsubscribePushCommand(userId, endpoint), + ); + } + /** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */ sendDueReminders(day: string): Promise { return this.commandBus.execute(new SendDueRemindersCommand(day)); diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts b/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts index b2fe064..cdeeefb 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.commands.ts @@ -1,5 +1,8 @@ import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { assertThat } from '@task-stream/api/shared/domain'; import { NotificationsRepository } from '../ports/notifications.repository'; +import { PushSender } from '../ports/push-sender'; +import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository'; /* * Per-person state, outside the event store: whether a notification was read @@ -49,3 +52,64 @@ export class UpdateNotificationSettingsCommandHandler }); } } + +/** Push on this device: the browser's subscription is kept for its owner. */ +export class SubscribePushCommand extends Command { + constructor( + public readonly userId: string, + public readonly endpoint: string, + public readonly keys: { p256dh: string; auth: string }, + ) { + super(); + } +} + +/** Push off on this device. */ +export class UnsubscribePushCommand extends Command { + constructor( + public readonly userId: string, + public readonly endpoint: string, + ) { + super(); + } +} + +@CommandHandler(SubscribePushCommand) +export class SubscribePushCommandHandler + implements ICommandHandler +{ + constructor( + private readonly subscriptions: PushSubscriptionsRepository, + private readonly push: PushSender, + ) {} + + async execute(command: SubscribePushCommand): Promise { + assertThat(!!this.push.publicKey, 'Push is not set up here'); + // Push services are https; anything else is not a browser's endpoint. + assertThat( + /^https:\/\/[^\s]{8,2000}$/.test(command.endpoint), + 'Not a push endpoint', + ); + assertThat( + !!command.keys?.p256dh && !!command.keys?.auth, + 'The subscription keys are missing', + ); + await this.subscriptions.save({ + userId: command.userId, + endpoint: command.endpoint, + keys: { p256dh: command.keys.p256dh, auth: command.keys.auth }, + createdAt: new Date().toISOString(), + }); + } +} + +@CommandHandler(UnsubscribePushCommand) +export class UnsubscribePushCommandHandler + implements ICommandHandler +{ + constructor(private readonly subscriptions: PushSubscriptionsRepository) {} + + async execute(command: UnsubscribePushCommand): Promise { + await this.subscriptions.remove(command.userId, command.endpoint); + } +} diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts b/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts index 27de344..7a7c1ea 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.queries.ts @@ -1,6 +1,7 @@ import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; import { NotificationReadModel } from '@task-stream/api/tracker/domain'; import { NotificationMailer } from '../ports/notification-mailer'; +import { PushSender } from '../ports/push-sender'; import { NotificationsRepository } from '../ports/notifications.repository'; export interface MyNotifications { @@ -13,6 +14,8 @@ export interface MyNotificationSettings { email: boolean; /** Whether this installation can send e-mail at all. */ emailAvailable: boolean; + /** VAPID public key to subscribe a device with; null: no push here. */ + pushPublicKey: string | null; } export class GetMyNotificationsQuery extends Query { @@ -57,6 +60,7 @@ export class GetMyNotificationSettingsQueryHandler constructor( private readonly notifications: NotificationsRepository, private readonly mailer: NotificationMailer, + private readonly push: PushSender, ) {} async execute( @@ -66,6 +70,7 @@ export class GetMyNotificationSettingsQueryHandler return { email: this.mailer.enabled && settings.email, emailAvailable: this.mailer.enabled, + pushPublicKey: this.push.publicKey, }; } } diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts index 4d54a62..76a407e 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -24,6 +24,12 @@ import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationsRepository } from '../ports/notifications.repository'; import { NotificationsEventHandler } from './notifications.event-handler'; import { Notifier } from './notifier'; +import { + PushPayload, + PushSender, + PushSubscriptionRecord, +} from '../ports/push-sender'; +import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository'; class InMemoryNotifications extends NotificationsRepository { readonly all: NotificationReadModel[] = []; @@ -56,10 +62,14 @@ const at = (event: T, version = 2): T => occurredAt: '2026-10-08T10:00:00.000Z', }); -function setup(mailEnabled = false) { +function setup(mailEnabled = false, pushEnabled = false) { const repo = new InMemoryNotifications(); const published: NotificationReadModel[] = []; const mailed: string[] = []; + /** Endpoint → what was pushed to it. */ + const pushed: [string, PushPayload][] = []; + const devices: PushSubscriptionRecord[] = []; + const forgotten: string[] = []; const users = new FakeUserDirectory() .add('ana') .add('bob') @@ -90,6 +100,21 @@ function setup(mailEnabled = false) { }, } as unknown as NotificationMailer, users, + { + publicKey: pushEnabled ? 'vapid-public' : null, + send: async (device: PushSubscriptionRecord, payload: PushPayload) => { + pushed.push([device.endpoint, payload]); + // A browser that dropped the subscription. + return device.endpoint.endsWith('/gone') ? 'gone' : 'sent'; + }, + } as unknown as PushSender, + { + forUser: async (userId: string) => + devices.filter((d) => d.userId === userId), + forget: async (endpoint: string) => { + forgotten.push(endpoint); + }, + } as unknown as PushSubscriptionsRepository, ); const handler = new NotificationsEventHandler( notifier, @@ -118,7 +143,7 @@ function setup(mailEnabled = false) { id === 's-14' ? { id, name: 'Sprint 14' } : null, } as unknown as FindSprintsRepository, ); - return { repo, published, mailed, handler }; + return { repo, published, mailed, pushed, devices, forgotten, handler }; } describe('notifications', () => { @@ -340,4 +365,32 @@ describe('notifications', () => { NotificationKind.ADDED_TO_PROJECT, ]); }); + + it('pushes to every device of whoever is told, and forgets dropped ones', async () => { + const { handler, pushed, devices, forgotten } = setup(false, true); + const device = (userId: string, endpoint: string) => ({ + userId, + endpoint, + keys: { p256dh: 'p', auth: 'a' }, + createdAt: '2026-10-01T00:00:00.000Z', + }); + devices.push( + device('bob', 'https://push.example/phone'), + device('bob', 'https://push.example/gone'), + device('qa', 'https://push.example/qa'), + ); + await handler.handle( + at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)), + ); + + expect(pushed.map(([endpoint]) => endpoint)).toEqual([ + 'https://push.example/phone', + 'https://push.example/gone', + ]); + expect(pushed[0][1]).toMatchObject({ + title: 'TaskStream · WEB-1', + url: '/p/WEB/issues/WEB-1', + }); + expect(forgotten).toEqual(['https://push.example/gone']); + }); }); diff --git a/libs/api/tracker/application/src/lib/notifications/notifier.ts b/libs/api/tracker/application/src/lib/notifications/notifier.ts index 5c09f8b..9c96144 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifier.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifier.ts @@ -9,6 +9,8 @@ import { import { ChangeFeed } from '../ports/change-feed'; import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationsRepository } from '../ports/notifications.repository'; +import { PushPayload, PushSender } from '../ports/push-sender'; +import { PushSubscriptionsRepository } from '../ports/push-subscriptions.repository'; import { UserDirectory } from '../ports/user-directory'; import { versionOf } from '../event-handlers/read-model-notifier'; @@ -39,6 +41,8 @@ export class Notifier { private readonly changeFeed: ChangeFeed, private readonly mailer: NotificationMailer, private readonly users: UserDirectory, + private readonly push: PushSender, + private readonly pushSubscriptions: PushSubscriptionsRepository, ) {} async deliver( @@ -89,6 +93,33 @@ export class Notifier { if (this.mailer.enabled && delivered.length) { await this.email(delivered); } + if (this.push.publicKey && delivered.length) { + await this.pushToDevices(delivered); + } + } + + /** + * To every device where the person turned push on. Like e-mail, a failure + * never blocks the event stream; a device the push service forgot is + * forgotten here too. + */ + private async pushToDevices( + notifications: NotificationReadModel[], + ): Promise { + for (const notification of notifications) { + const devices = await this.pushSubscriptions.forUser(notification.userId); + for (const device of devices) { + try { + const result = await this.push.send(device, payloadOf(notification)); + if (result === 'gone') + await this.pushSubscriptions.forget(device.endpoint); + } catch (error) { + this.logger.warn( + `Push to ${notification.userId} failed: ${(error as Error).message}`, + ); + } + } + } } /** A failed e-mail never blocks the event stream: it is logged and skipped. */ @@ -116,3 +147,18 @@ export class Notifier { } } } + +/** What the device shows: the ticket (or project), the line and its page. */ +export function payloadOf(notification: NotificationReadModel): PushPayload { + const project = encodeURIComponent(notification.projectKey); + return { + title: notification.issueKey + ? `TaskStream · ${notification.issueKey}` + : `TaskStream · ${notification.projectKey}`, + body: notification.summary, + url: notification.issueKey + ? `/p/${project}/issues/${encodeURIComponent(notification.issueKey)}` + : `/p/${project}`, + tag: notification.id, + }; +} diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 119bdfa..9fe64d3 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -28,3 +28,5 @@ export * from './outgoing-webhooks.repository'; export * from './automations.repository'; export * from './rate-limit.store'; export * from './calendar-feeds.repository'; +export * from './push-sender'; +export * from './push-subscriptions.repository'; diff --git a/libs/api/tracker/application/src/lib/ports/push-sender.ts b/libs/api/tracker/application/src/lib/ports/push-sender.ts new file mode 100644 index 0000000..69d4e67 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/push-sender.ts @@ -0,0 +1,32 @@ +/** A browser's push subscription (Web Push), as the browser hands it over. */ +export interface PushSubscriptionRecord { + userId: string; + /** The push service URL of that browser on that device: unique. */ + endpoint: string; + keys: { p256dh: string; auth: string }; + createdAt: string; +} + +/** What the service worker shows: a title, a line and where a tap goes. */ +export interface PushPayload { + title: string; + body: string; + /** A path in the web app, e.g. `/p/WEB/issues/WEB-12`. */ + url: string; + /** Replaces an earlier notification with the same tag on the device. */ + tag: string; +} + +/** + * Sends Web Push messages (VAPID). Without keys configured it is off and + * nobody is offered push. + */ +export abstract class PushSender { + /** The VAPID public key browsers subscribe with; null: push is off. */ + abstract get publicKey(): string | null; + /** `gone`: the browser dropped the subscription; forget it. */ + abstract send( + subscription: PushSubscriptionRecord, + payload: PushPayload, + ): Promise<'sent' | 'gone'>; +} diff --git a/libs/api/tracker/application/src/lib/ports/push-subscriptions.repository.ts b/libs/api/tracker/application/src/lib/ports/push-subscriptions.repository.ts new file mode 100644 index 0000000..53bfa55 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/push-subscriptions.repository.ts @@ -0,0 +1,12 @@ +import { PushSubscriptionRecord } from './push-sender'; + +/** The devices where each person turned push on. */ +export abstract class PushSubscriptionsRepository { + abstract forUser(userId: string): Promise; + /** By endpoint: a device subscribing again replaces its entry. */ + abstract save(subscription: PushSubscriptionRecord): Promise; + /** Only the owner's: nobody drops someone else's device. */ + abstract remove(userId: string, endpoint: string): Promise; + /** The push service said it no longer exists. */ + abstract forget(endpoint: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 9ea0d7b..d645a0b 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -29,6 +29,8 @@ import { import { MarkNotificationsReadCommandHandler, UpdateNotificationSettingsCommandHandler, + SubscribePushCommandHandler, + UnsubscribePushCommandHandler, } from './notifications/notifications.commands'; import { SAVED_FILTERS } from './saved-filters/saved-filters'; @@ -55,6 +57,8 @@ const NOTIFICATIONS = [ GetMyNotificationSettingsQueryHandler, MarkNotificationsReadCommandHandler, UpdateNotificationSettingsCommandHandler, + SubscribePushCommandHandler, + UnsubscribePushCommandHandler, SendDueRemindersCommandHandler, ]; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/push-subscriptions.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/push-subscriptions.repository.ts new file mode 100644 index 0000000..b41b0e8 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/push-subscriptions.repository.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + PushSubscriptionRecord, + PushSubscriptionsRepository, +} from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { PushSubscriptionDoc, ReadModels } from './read-model.schemas'; + +/** Endpoints are long URLs: their hash makes a tidy, unique `id`. */ +const idOf = (endpoint: string) => + createHash('sha256').update(endpoint).digest('hex'); + +@Injectable() +export class MongoPushSubscriptionsRepository extends PushSubscriptionsRepository { + constructor( + @InjectModel(ReadModels.PushSubscription) + private readonly subscriptions: Model, + ) { + super(); + } + + async forUser(userId: string): Promise { + const docs = await this.subscriptions + .find({ userId }) + .lean(); + return docs.map(({ userId: owner, endpoint, keys, createdAt }) => ({ + userId: owner, + endpoint, + keys, + createdAt, + })); + } + + async save(subscription: PushSubscriptionRecord): Promise { + await this.subscriptions.updateOne( + { id: idOf(subscription.endpoint) }, + { $set: subscription }, + { upsert: true }, + ); + } + + async remove(userId: string, endpoint: string): Promise { + await this.subscriptions.deleteOne({ id: idOf(endpoint), userId }); + } + + async forget(endpoint: string): Promise { + await this.subscriptions.deleteOne({ id: idOf(endpoint) }); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 65a50f0..3dc4322 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -64,6 +64,14 @@ export interface GitIntegrationDoc { secret?: string; createdAt?: string; } +export interface PushSubscriptionDoc { + /** sha256 of the endpoint. */ + id: string; + userId: string; + endpoint: string; + keys: { p256dh: string; auth: string }; + createdAt: string; +} export interface CalendarFeedDoc { /** The user's id. */ id: string; @@ -92,6 +100,7 @@ export const ReadModels = { Automation: 'Automation', AutomationRun: 'AutomationRun', CalendarFeed: 'CalendarFeed', + PushSubscription: 'PushSubscription', } as const; /* @@ -347,6 +356,20 @@ class CalendarFeedDocument { export const CalendarFeedSchema = SchemaFactory.createForClass(CalendarFeedDocument); +/** The devices where each person turned push on. */ +@Schema({ ...LOOSE, collection: 'push_subscriptions' }) +class PushSubscriptionDocument { + /** sha256 of the endpoint. */ + @Prop({ type: String, required: true, unique: true }) id!: string; + @Prop({ type: String, index: true }) userId!: string; + @Prop({ type: String }) endpoint!: string; + @Prop({ type: Object }) keys!: { p256dh: string; auth: string }; + @Prop({ type: String }) createdAt!: string; +} +export const PushSubscriptionSchema = SchemaFactory.createForClass( + PushSubscriptionDocument, +); + // Redeliveries happen within minutes; a month of memory is plenty. AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 }); @@ -366,6 +389,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, + { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, ]; /** A document as the model it holds: Mongo's own `_id` left out. */ diff --git a/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.spec.ts b/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.spec.ts new file mode 100644 index 0000000..0cddcbd --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.spec.ts @@ -0,0 +1,110 @@ +import { createECDH, randomBytes } from 'node:crypto'; +import { ConfigService } from '@nestjs/config'; +import * as webpush from 'web-push'; +import { WebPushSender } from './web-push.sender'; + +// The real library, but nothing leaves the machine. +jest.mock('web-push', () => ({ + ...jest.requireActual('web-push'), + sendNotification: jest.fn(), +})); +const sendNotification = webpush.sendNotification as jest.MockedFunction< + typeof webpush.sendNotification +>; + +/** What a browser hands over: its P-256 public key and an auth secret. */ +function device(endpoint = 'https://push.example/abc') { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + return { + userId: 'ana', + endpoint, + keys: { + p256dh: ecdh.getPublicKey().toString('base64url'), + auth: randomBytes(16).toString('base64url'), + }, + createdAt: '2026-10-15T00:00:00.000Z', + }; +} + +const VAPID = webpush.generateVAPIDKeys(); + +function sender(withKeys = true) { + const values: Record = withKeys + ? { + VAPID_PUBLIC_KEY: VAPID.publicKey, + VAPID_PRIVATE_KEY: VAPID.privateKey, + VAPID_SUBJECT: 'mailto:test@example.org', + } + : {}; + return new WebPushSender({ + get: (key: string) => values[key], + } as unknown as ConfigService); +} + +const PAYLOAD = { + title: 'TaskStream · WEB-1', + body: 'Ana te asignó WEB-1', + url: '/p/WEB/issues/WEB-1', + tag: 'n-1', +}; + +describe('WebPushSender', () => { + afterEach(() => sendNotification.mockReset()); + + it('is off without VAPID keys, on with them', () => { + expect(sender(false).publicKey).toBeNull(); + expect(sender().publicKey).toBe(VAPID.publicKey); + }); + + it('builds an encrypted, VAPID-signed message for the device', () => { + sender(); + const subscriber = device(); + const request = webpush.generateRequestDetails( + { endpoint: subscriber.endpoint, keys: subscriber.keys }, + JSON.stringify(PAYLOAD), + { TTL: 86_400 }, + ); + expect(request.headers['Content-Encoding']).toBe('aes128gcm'); + expect(String(request.headers['Authorization'])).toMatch( + new RegExp(`^vapid t=.+, k=${VAPID.publicKey}$`), + ); + // Encrypted: nothing of the text travels in the clear. + expect(String(request.body)).not.toContain('WEB-1'); + }); + + it('sends the payload as JSON and keeps it a day', async () => { + sendNotification.mockResolvedValue({ + statusCode: 201, + body: '', + headers: {}, + }); + expect(await sender().send(device(), PAYLOAD)).toBe('sent'); + const [target, body, options] = sendNotification.mock.calls[0]; + expect(target.endpoint).toBe('https://push.example/abc'); + expect(JSON.parse(String(body))).toEqual(PAYLOAD); + expect(options).toMatchObject({ TTL: 86_400 }); + }); + + it('tells a subscription the push service dropped (404, 410)', async () => { + for (const status of [404, 410]) { + sendNotification.mockRejectedValueOnce( + new webpush.WebPushError( + 'gone', + status, + {}, + '', + 'https://push.example/abc', + ), + ); + expect(await sender().send(device(), PAYLOAD)).toBe('gone'); + } + }); + + it('lets other failures through, to be logged', async () => { + sendNotification.mockRejectedValue( + new webpush.WebPushError('busy', 503, {}, '', 'https://push.example/abc'), + ); + await expect(sender().send(device(), PAYLOAD)).rejects.toThrow('busy'); + }); +}); diff --git a/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.ts b/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.ts new file mode 100644 index 0000000..91a35d1 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/push/web-push.sender.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + PushPayload, + PushSender, + PushSubscriptionRecord, +} from '@task-stream/api/tracker/application'; +import * as webpush from 'web-push'; + +/** + * Web Push with VAPID keys (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and + * VAPID_SUBJECT, a `mailto:` or https URL the push services can reach + * someone at). Make the keys once with `npx web-push generate-vapid-keys`; + * changing them drops every subscription. Without them push is off. + */ +@Injectable() +export class WebPushSender extends PushSender { + private readonly logger = new Logger(WebPushSender.name); + private readonly key: string | null; + + constructor(config: ConfigService) { + super(); + const publicKey = config.get('VAPID_PUBLIC_KEY') || ''; + const privateKey = config.get('VAPID_PRIVATE_KEY') || ''; + const subject = + config.get('VAPID_SUBJECT') || 'mailto:no-reply@pireon.dev'; + this.key = publicKey && privateKey ? publicKey : null; + if (this.key) { + webpush.setVapidDetails(subject, publicKey, privateKey); + this.logger.log('Push notifications enabled'); + } + } + + get publicKey(): string | null { + return this.key; + } + + async send( + subscription: PushSubscriptionRecord, + payload: PushPayload, + ): Promise<'sent' | 'gone'> { + if (!this.key) return 'sent'; + try { + await webpush.sendNotification( + { endpoint: subscription.endpoint, keys: subscription.keys }, + JSON.stringify(payload), + // Kept a day by the push service while the device is off. + { TTL: 86_400, urgency: 'normal' }, + ); + return 'sent'; + } catch (error) { + // 404 / 410: that browser dropped the subscription for good. + if ( + error instanceof webpush.WebPushError && + (error.statusCode === 404 || error.statusCode === 410) + ) + return 'gone'; + throw error; + } + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 63c3a98..400b7e2 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -8,6 +8,8 @@ import { NotificationMailer, NotificationsRepository, CalendarFeedsRepository, + PushSubscriptionsRepository, + PushSender, SavedFiltersRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, @@ -67,6 +69,8 @@ import { RedisChangeFeed } from './realtime/redis-change-feed'; import { RedisRateLimitStore } from './redis/redis-rate-limit.store'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'; +import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository'; +import { WebPushSender } from './push/web-push.sender'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; @@ -133,6 +137,11 @@ const PORTS = [ // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) { provide: NotificationsRepository, useClass: MongoNotificationsRepository }, { provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository }, + { + provide: PushSubscriptionsRepository, + useClass: MongoPushSubscriptionsRepository, + }, + { provide: PushSender, useClass: WebPushSender }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: GitIntegrationsRepository, diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 2e0f42e..9aa7e0c 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -689,3 +689,14 @@ export class ProjectSettingsInput { @Type(() => AreaOptionInput) areas!: AreaOptionInput[]; } + +@InputType({ + description: "A browser's Web Push subscription (PushSubscription.toJSON())", +}) +export class PushSubscriptionInput { + @Field() + @IsUrl({ protocols: ['https'], require_tld: false }) + endpoint!: string; + @Field() @IsString() @MaxLength(200) p256dh!: string; + @Field() @IsString() @MaxLength(100) auth!: string; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts index 113179b..bbb95cd 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/notification.resolver.ts @@ -7,6 +7,7 @@ import { NotificationGql, NotificationSettingsGql, } from './types'; +import { PushSubscriptionInput } from './inputs'; /** The signed-in person's notifications. */ @Resolver(() => NotificationGql) @@ -40,6 +41,27 @@ export class NotificationResolver { return true; } + @Mutation(() => Boolean, { description: 'Push on this device' }) + async subscribePush( + @CurrentUser() me: User, + @Args('subscription') subscription: PushSubscriptionInput, + ) { + await this.notifications.subscribePush(me.id, subscription.endpoint, { + p256dh: subscription.p256dh, + auth: subscription.auth, + }); + return true; + } + + @Mutation(() => Boolean, { description: 'Push off on this device' }) + async unsubscribePush( + @CurrentUser() me: User, + @Args('endpoint') endpoint: string, + ) { + await this.notifications.unsubscribePush(me.id, endpoint); + return true; + } + @Mutation(() => NotificationSettingsGql) updateNotificationSettings( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 0fd44af..4a785f0 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -642,6 +642,12 @@ export class NotificationSettingsGql { description: 'Whether this installation can send e-mail at all', }) emailAvailable!: boolean; + @Field(() => String, { + nullable: true, + description: + 'VAPID public key to subscribe a device to push with; null: no push here', + }) + pushPublicKey!: string | null; } @ObjectType('CommandResult', { diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 13474cb..e9a1ecc 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -1212,6 +1212,31 @@ export class NotificationSettingsDto { email!: boolean; } +export class PushSubscriptionDto { + @ApiProperty({ + description: 'The push service URL (PushSubscription.endpoint)', + }) + @IsUrl({ protocols: ['https'], require_tld: false }) + endpoint!: string; + + @ApiProperty({ description: 'keys.p256dh of the subscription' }) + @IsString() + @MaxLength(200) + p256dh!: string; + + @ApiProperty({ description: 'keys.auth of the subscription' }) + @IsString() + @MaxLength(100) + auth!: string; +} + +export class PushEndpointDto { + @ApiProperty({ description: 'The endpoint of the device to turn off' }) + @IsString() + @MaxLength(2000) + endpoint!: string; +} + // ---- Git -------------------------------------------------------------------- export class AddGitRepositoryDto { diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 7cccac3..d87214d 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -844,4 +844,11 @@ export class NotificationSettingsResponseDto { @ApiProperty({ description: 'Copies by e-mail' }) email!: boolean; @ApiProperty({ description: 'Whether this installation can send e-mail' }) emailAvailable!: boolean; + @ApiProperty({ + type: String, + nullable: true, + description: + 'VAPID public key to subscribe a device to push with; null: no push here', + }) + pushPublicKey!: string | null; } diff --git a/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts b/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts index dcea845..2c50388 100644 --- a/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/notifications.controller.ts @@ -1,5 +1,19 @@ -import { Body, Controller, Get, Post, Put, Query } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { User } from '@task-stream/api/identity/domain'; import { ApiPireonAuth, @@ -14,6 +28,8 @@ import { MarkNotificationsReadDto, MyNotificationsQueryDto, NotificationSettingsDto, + PushEndpointDto, + PushSubscriptionDto, } from './dto/requests.dto'; import { MyNotificationsDto, @@ -67,4 +83,32 @@ export class NotificationsController { ) { return this.notifications.updateSettings(me.id, body); } + + @Post('push-subscriptions') + @HttpCode(204) + @ApiOperation({ + summary: "Push on this device (a browser's Web Push subscription)", + }) + @ApiNoContentResponse() + @ApiCommandErrors() + async subscribePush( + @CurrentUser() me: User, + @Body() body: PushSubscriptionDto, + ): Promise { + await this.notifications.subscribePush(me.id, body.endpoint, { + p256dh: body.p256dh, + auth: body.auth, + }); + } + + @Delete('push-subscriptions') + @HttpCode(204) + @ApiOperation({ summary: 'Push off on this device' }) + @ApiNoContentResponse() + async unsubscribePush( + @CurrentUser() me: User, + @Body() body: PushEndpointDto, + ): Promise { + await this.notifications.unsubscribePush(me.id, body.endpoint); + } } diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 32e0576..65d73d3 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/notifications'; export * from './lib/facades/projects.facade'; export * from './lib/facades/calendar.facade'; +export * from './lib/facades/push.facade'; export * from './lib/facades/project.facade'; export * from './lib/facades/board.facade'; export * from './lib/facades/issue.facade'; diff --git a/libs/web/tracker/application/src/lib/facades/push.facade.ts b/libs/web/tracker/application/src/lib/facades/push.facade.ts new file mode 100644 index 0000000..625d4fb --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/push.facade.ts @@ -0,0 +1,60 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + NotificationsGateway, + PushDevice, +} from '@task-stream/web/tracker/domain'; +import { firstValueFrom } from 'rxjs'; + +/** + * Push on this device: the browser subscribes and the API keeps it, so the + * notifications of the bell also reach the device with TaskStream closed. + */ +@Injectable({ providedIn: 'root' }) +export class PushFacade { + private readonly device = inject(PushDevice); + private readonly gateway = inject(NotificationsGateway); + + readonly supported = this.device.supported; + readonly on = signal(false); + readonly busy = signal(false); + readonly error = signal(null); + + /** Whether this browser has push on now. */ + async refresh(): Promise { + this.on.set(!!(await this.device.current().catch(() => null))); + } + + async turnOn(publicKey: string): Promise { + await this.run(async () => { + const subscription = await this.device.subscribe(publicKey); + await firstValueFrom(this.gateway.subscribePush(subscription)); + this.on.set(true); + }); + } + + async turnOff(): Promise { + await this.run(async () => { + const endpoint = await this.device.unsubscribe(); + if (endpoint) + await firstValueFrom(this.gateway.unsubscribePush(endpoint)); + this.on.set(false); + }); + } + + private async run(step: () => Promise): Promise { + this.busy.set(true); + this.error.set(null); + try { + await step(); + } catch (error) { + this.error.set( + error instanceof Error && error.message + ? error.message + : 'No se han podido activar los avisos', + ); + await this.refresh(); + } finally { + this.busy.set(false); + } + } +} diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 2c00ef7..848a1e0 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -441,4 +441,13 @@ export interface NotificationSettings { email: boolean; /** Whether this installation can send e-mail at all. */ emailAvailable: boolean; + /** VAPID key to subscribe a device to push with; null: no push here. */ + pushPublicKey: string | null; +} + +/** A browser's Web Push subscription, as the API takes it. */ +export interface PushDeviceSubscription { + endpoint: string; + p256dh: string; + auth: string; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index c73d8d1..bf486ab 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,7 @@ import { AutomationInput, BoardColumn, CalendarFeed, + PushDeviceSubscription, CommandResult, Comment, Epic, @@ -350,6 +351,26 @@ export abstract class NotificationsGateway { abstract markRead(ids?: string[]): Observable; abstract settings(): Observable; abstract updateSettings(email: boolean): Observable; + /** Push on this device. */ + abstract subscribePush( + subscription: PushDeviceSubscription, + ): Observable; + /** Push off on this device. */ + abstract unsubscribePush(endpoint: string): Observable; +} + +/** + * This browser's side of push: its service worker and push subscription. + * Not every browser has it (and on iPhone only an installed web app does). + */ +export abstract class PushDevice { + abstract get supported(): boolean; + /** This browser's subscription, if push is on here. */ + abstract current(): Promise; + /** Asks for permission and subscribes; throws when refused. */ + abstract subscribe(publicKey: string): Promise; + /** Unsubscribes; the endpoint it had, if any. */ + abstract unsubscribe(): Promise; } /** Realtime (GraphQL subscriptions over WebSocket). */ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/browser-push.device.ts b/libs/web/tracker/infrastructure/src/lib/adapters/browser-push.device.ts new file mode 100644 index 0000000..8604496 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/browser-push.device.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@angular/core'; +import { + PushDevice, + PushDeviceSubscription, +} from '@task-stream/web/tracker/domain'; + +/** The service worker that shows the notifications (apps/web/public). */ +const WORKER = '/push-sw.js'; + +/** Push through this browser's service worker and PushManager. */ +@Injectable() +export class BrowserPushDevice extends PushDevice { + get supported(): boolean { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); + } + + async current(): Promise { + if (!this.supported) return null; + const registration = await navigator.serviceWorker.getRegistration(WORKER); + const subscription = await registration?.pushManager.getSubscription(); + return subscription ? toDevice(subscription) : null; + } + + async subscribe(publicKey: string): Promise { + if (!this.supported) throw new Error('Este navegador no admite avisos'); + if ((await Notification.requestPermission()) !== 'granted') + throw new Error( + 'El navegador no tiene permiso para mostrar avisos: dáselo en los ajustes del sitio.', + ); + const registration = await navigator.serviceWorker.register(WORKER); + await navigator.serviceWorker.ready; + try { + const subscription = + (await registration.pushManager.getSubscription()) ?? + (await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: fromBase64Url(publicKey), + })); + return toDevice(subscription); + } catch { + // The browser's own message is in English and says little. + throw new Error( + 'Este navegador no ha dejado activar los avisos. En iPhone, añade antes TaskStream a la pantalla de inicio.', + ); + } + } + + async unsubscribe(): Promise { + if (!this.supported) return null; + const registration = await navigator.serviceWorker.getRegistration(WORKER); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) return null; + await subscription.unsubscribe(); + return subscription.endpoint; + } +} + +function toDevice(subscription: PushSubscription): PushDeviceSubscription { + const json = subscription.toJSON(); + return { + endpoint: subscription.endpoint, + p256dh: json.keys?.['p256dh'] ?? '', + auth: json.keys?.['auth'] ?? '', + }; +} + +/** VAPID keys travel as base64url; PushManager wants the bytes. */ +function fromBase64Url(text: string): Uint8Array { + const base64 = (text + '='.repeat((4 - (text.length % 4)) % 4)) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const raw = atob(base64); + const bytes = new Uint8Array(new ArrayBuffer(raw.length)); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes; +} diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts index 4563309..11b1410 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-notifications.gateway.ts @@ -3,12 +3,15 @@ import { Notification, NotificationSettings, NotificationsGateway, + PushDeviceSubscription, } from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { MarkNotificationsReadDocument, MyNotificationsDocument, NotificationSettingsDocument, + SubscribePushDocument, + UnsubscribePushDocument, UpdateNotificationSettingsDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; @@ -46,4 +49,16 @@ export class GraphqlNotificationsGateway extends NotificationsGateway { .mutate(UpdateNotificationSettingsDocument, { email }) .pipe(map((d) => ({ ...d.updateNotificationSettings }))); } + + subscribePush(subscription: PushDeviceSubscription): Observable { + return this.gql + .mutate(SubscribePushDocument, { subscription }) + .pipe(map(() => undefined)); + } + + unsubscribePush(endpoint: string): Observable { + return this.gql + .mutate(UnsubscribePushDocument, { endpoint }) + .pipe(map(() => undefined)); + } } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 9937aff..d056431 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -236,6 +236,13 @@ export type ProjectSettingsInput = { transitions: Array; }; +/** A browser's Web Push subscription (PushSubscription.toJSON()) */ +export type PushSubscriptionInput = { + auth: string; + endpoint: string; + p256dh: string; +}; + export type RankIssueInput = { /** The ticket that ends up right above (null: the top) */ aboveIssueId?: string | null | undefined; @@ -1366,7 +1373,11 @@ export type NotificationSettingsQueryVariables = Exact<{ }>; export type NotificationSettingsQuery = { - notificationSettings: { email: boolean; emailAvailable: boolean }; + notificationSettings: { + email: boolean; + emailAvailable: boolean; + pushPublicKey: string | null; + }; }; export type MarkNotificationsReadMutationVariables = Exact<{ @@ -1380,9 +1391,25 @@ export type UpdateNotificationSettingsMutationVariables = Exact<{ }>; export type UpdateNotificationSettingsMutation = { - updateNotificationSettings: { email: boolean; emailAvailable: boolean }; + updateNotificationSettings: { + email: boolean; + emailAvailable: boolean; + pushPublicKey: string | null; + }; }; +export type SubscribePushMutationVariables = Exact<{ + subscription: PushSubscriptionInput; +}>; + +export type SubscribePushMutation = { subscribePush: boolean }; + +export type UnsubscribePushMutationVariables = Exact<{ + endpoint: string; +}>; + +export type UnsubscribePushMutation = { unsubscribePush: boolean }; + export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>; @@ -8442,6 +8469,10 @@ export const NotificationSettingsDocument = { kind: 'Field', name: { kind: 'Name', value: 'emailAvailable' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'pushPublicKey' }, + }, ], }, }, @@ -8545,6 +8576,10 @@ export const UpdateNotificationSettingsDocument = { kind: 'Field', name: { kind: 'Name', value: 'emailAvailable' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'pushPublicKey' }, + }, ], }, }, @@ -8556,6 +8591,102 @@ export const UpdateNotificationSettingsDocument = { UpdateNotificationSettingsMutation, UpdateNotificationSettingsMutationVariables >; +export const SubscribePushDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'SubscribePush' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'subscription' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'PushSubscriptionInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'subscribePush' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'subscription' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'subscription' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + SubscribePushMutation, + SubscribePushMutationVariables +>; +export const UnsubscribePushDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UnsubscribePush' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'endpoint' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'unsubscribePush' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'endpoint' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'endpoint' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UnsubscribePushMutation, + UnsubscribePushMutationVariables +>; export const NotificationAddedDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql index 7978676..dd077dc 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/notifications.graphql @@ -26,6 +26,7 @@ query NotificationSettings { notificationSettings { email emailAvailable + pushPublicKey } } @@ -37,9 +38,18 @@ mutation UpdateNotificationSettings($email: Boolean!) { updateNotificationSettings(email: $email) { email emailAvailable + pushPublicKey } } +mutation SubscribePush($subscription: PushSubscriptionInput!) { + subscribePush(subscription: $subscription) +} + +mutation UnsubscribePush($endpoint: String!) { + unsubscribePush(endpoint: $endpoint) +} + subscription NotificationAdded { notificationAdded { ...NotificationFields diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 991e9d8..b681f0d 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -10,6 +10,7 @@ import { IssuesGateway, NotificationsGateway, CalendarGateway, + PushDevice, ProjectsGateway, ReleasesGateway, SavedFiltersGateway, @@ -30,6 +31,7 @@ import { import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway'; import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway'; import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway'; +import { BrowserPushDevice } from './adapters/browser-push.device'; import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { HttpProposalsGateway } from './adapters/http-proposals.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; @@ -54,6 +56,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: CalendarGateway, useClass: GraphqlCalendarGateway }, + { provide: PushDevice, useClass: BrowserPushDevice }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, diff --git a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts index 397dbcc..5ae5693 100644 --- a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts +++ b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts @@ -14,7 +14,7 @@ import { PiSlideToggleComponent, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; -import { InboxFacade } from '@task-stream/web/tracker/application'; +import { InboxFacade, PushFacade } from '@task-stream/web/tracker/application'; import { Notification, NotificationKind, @@ -81,13 +81,30 @@ const ICON: Record = { (execute)="inbox.markRead()" /> @if (inbox.settings()?.emailAvailable) { - +
+ + +
+ } + @if (inbox.settings()?.pushPublicKey && push.supported) { +
+ + +
} + @if (push.error(); as error) { + + } @if (inbox.items().length) {
    @for (item of inbox.items(); track item.id) { @@ -139,6 +156,17 @@ const ICON: Record = { justify-content: space-between; gap: 0.75rem; } + .ts-inbox__switch { + display: inline-flex; + gap: 0.5rem; + align-items: center; + font-size: 0.875rem; + } + .ts-inbox__error { + margin: 0; + color: var(--ts-c-red, var(--ts-ink)); + font-size: 0.8125rem; + } .ts-inbox__list { list-style: none; margin: 0; @@ -201,6 +229,7 @@ const ICON: Record = { }) export class NotificationsBell { protected readonly inbox = inject(InboxFacade); + protected readonly push = inject(PushFacade); private readonly drawer = inject(DrawerService); private readonly router = inject(Router); private readonly panel = viewChild.required>('panel'); @@ -209,9 +238,16 @@ export class NotificationsBell { protected icon = (item: Notification) => ICON[item.kind] ?? 'info'; protected open(): void { + void this.push.refresh(); this.drawer.open('Notificaciones', this.panel(), '28rem'); } + protected togglePush(on: boolean): void { + const key = this.inbox.settings()?.pushPublicKey; + if (on && key) void this.push.turnOn(key); + else if (!on) void this.push.turnOff(); + } + protected go(item: Notification): void { if (!item.readAt) this.inbox.markRead([item.id]); this.drawer.closeAll(); diff --git a/package-lock.json b/package-lock.json index 7ee2aaa..33226ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "rxjs": "^7.8.0", "tslib": "^2.8.1", "typeorm": "^0.3.31", + "web-push": "3.6.7", "ws": "^8.22.0", "zod": "^4.6.5" }, @@ -105,6 +106,7 @@ "@types/jest": "~30.0.0", "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.2", + "@types/web-push": "3.6.4", "@types/ws": "^8.18.2", "@typescript-eslint/utils": "^8.58.0", "@vitest/coverage-v8": "~4.1.0", @@ -14957,6 +14959,16 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -16017,6 +16029,18 @@ "node": ">=8" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -16488,6 +16512,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -16652,6 +16682,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, @@ -18210,6 +18246,15 @@ "version": "0.2.0", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -20303,6 +20348,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -23986,6 +24040,27 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -25182,7 +25257,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, "license": "ISC" }, "node_modules/minimatch": { @@ -25201,7 +25275,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -32424,6 +32497,47 @@ "license": "MIT", "optional": true }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index fd0a325..0b3cde7 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "rxjs": "^7.8.0", "tslib": "^2.8.1", "typeorm": "^0.3.31", + "web-push": "3.6.7", "ws": "^8.22.0", "zod": "^4.6.5" }, @@ -101,6 +102,7 @@ "@types/jest": "~30.0.0", "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.2", + "@types/web-push": "3.6.4", "@types/ws": "^8.18.2", "@typescript-eslint/utils": "^8.58.0", "@vitest/coverage-v8": "~4.1.0",