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

@@ -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. */