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:
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user