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

@@ -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<PushSubscriptionDoc>,
) {
super();
}
async forUser(userId: string): Promise<PushSubscriptionRecord[]> {
const docs = await this.subscriptions
.find({ userId })
.lean<PushSubscriptionDoc[]>();
return docs.map(({ userId: owner, endpoint, keys, createdAt }) => ({
userId: owner,
endpoint,
keys,
createdAt,
}));
}
async save(subscription: PushSubscriptionRecord): Promise<void> {
await this.subscriptions.updateOne(
{ id: idOf(subscription.endpoint) },
{ $set: subscription },
{ upsert: true },
);
}
async remove(userId: string, endpoint: string): Promise<void> {
await this.subscriptions.deleteOne({ id: idOf(endpoint), userId });
}
async forget(endpoint: string): Promise<void> {
await this.subscriptions.deleteOne({ id: idOf(endpoint) });
}
}

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