@ours.network/notifications 0.3.1-nightly.1 → 0.3.1-nightly.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -2
- package/dist/chat-target.js +4 -1
- package/dist/server.js +27 -10
- package/dist/store.d.ts +31 -2
- package/dist/store.js +72 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,15 +55,29 @@ Responses are never cached. User and producer credentials have separate scopes.
|
|
|
55
55
|
| POST `/api/v1/devices/delete` | User | `{ "id": "device-id" }`, only that user's device |
|
|
56
56
|
| GET `/api/v1/devices/status?id=...` | User | `{ "active": true }` |
|
|
57
57
|
| GET `/api/v1/inbox?limit=50&before=...` | User | Items, shared unread count, revision, next cursor |
|
|
58
|
-
| GET `/api/v1/summary` | User | Shared unread count, revision,
|
|
58
|
+
| GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}` |
|
|
59
59
|
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats?chat=agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
|
|
60
60
|
| POST `/api/v1/read` | User | `{ "ids": ["notification-id"] }`, idempotent shared read |
|
|
61
|
+
| POST `/api/v1/delete` | User | `{ "ids": ["notification-id"] }` → `{ "removed": COUNT }`; persistently removes only this user’s entries and queued deliveries after the client has confirmed target absence |
|
|
62
|
+
| POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats?chat=agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
|
|
61
63
|
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats?chat=agent&detail=1" }` |
|
|
62
64
|
|
|
63
65
|
Inbox admission and queued delivery are one fsynced atomic snapshot. The service
|
|
64
66
|
deduplicates by user/source/event ID; replay must retain identical title/body/URL
|
|
65
67
|
or it receives 409. Inbox entries survive failed pushes and service restart.
|
|
66
|
-
|
|
68
|
+
Producer send payloads may include `taskId`: a stable task ID for an associated
|
|
69
|
+
ACP agent, or `null` for an explicitly standalone agent. Omission remains supported.
|
|
70
|
+
The association is persisted and cannot change when replaying the same event ID.
|
|
71
|
+
Summary section counts are independent of browser inventories: task-page URLs,
|
|
72
|
+
encoded work task rooms and explicit task associations count under Tasks. An explicit
|
|
73
|
+
task association takes precedence over the source. Messenger destinations, including
|
|
74
|
+
room CIDs that open in Messenger, and explicit standalone agents count under Chats.
|
|
75
|
+
Legacy ACP notifications without association
|
|
76
|
+
and unknown destinations remain in the total and Notifications inbox only. Each
|
|
77
|
+
unread contributes at most once to a section. Reading or deletion updates these
|
|
78
|
+
counts with the same durable revision as the total.
|
|
79
|
+
|
|
80
|
+
Reading stops remaining queued deliveries. Deleting confirmed stale inbox records updates the shared revision, unread summary and presence subscribers atomically. Generic loading, permission or network failures do not establish target absence; callers must validate absence through the owning service. Device endpoints cannot transfer
|
|
67
81
|
between users, and only supported HTTPS push-provider hosts and valid P-256
|
|
68
82
|
subscription keys are accepted. Navigation stays within `/fleet`.
|
|
69
83
|
|
|
@@ -126,3 +140,34 @@ limit remove frozen/disconnected clients. Only that foreground device's push
|
|
|
126
140
|
is suppressed; its inbox item remains unread until explicitly marked read.
|
|
127
141
|
Other devices still receive push. `inbox.changed` invalidates account-wide inbox
|
|
128
142
|
views after producer admission or reads. Presence is ephemeral across restart.
|
|
143
|
+
|
|
144
|
+
## Task lifecycle delivery and diagnostics
|
|
145
|
+
|
|
146
|
+
Task notifications from Fleet identify creation and every committed lifecycle change,
|
|
147
|
+
including block/unblock and deletion. Task routes remain eligible for push while a
|
|
148
|
+
registered device is foreground; ordinary chat delivery keeps its existing server
|
|
149
|
+
presence policy. The browser must display every push it has already received:
|
|
150
|
+
Safari can revoke subscriptions after silent pushes, so suppression must happen
|
|
151
|
+
before sending, never by returning from the worker's push handler.
|
|
152
|
+
|
|
153
|
+
`GET /api/v1/delivery/status` requires the owner's user credential (never a producer
|
|
154
|
+
credential). It returns active device/pending-job counts and the latest 100 safe
|
|
155
|
+
outcomes for that account: provider acceptance, foreground suppression, expired
|
|
156
|
+
endpoint, scheduled retry, cancellation or no registered device. The service retains
|
|
157
|
+
at most 1,000 outcomes per account. Records contain IDs, timestamps, attempt counts
|
|
158
|
+
and numeric provider status only; endpoints, encryption keys, notification contents
|
|
159
|
+
and provider bodies are excluded. `provider_accepted` proves provider acceptance,
|
|
160
|
+
not display or receipt on a phone.
|
|
161
|
+
|
|
162
|
+
Failed push jobs persist with exponential backoff capped at one hour until they
|
|
163
|
+
succeed, are read/deleted, or their endpoint expires (404/410). Restart preserves
|
|
164
|
+
retry work. Re-registering a subscription with changed keys gets a new device ID,
|
|
165
|
+
so a late expiry response cannot remove its replacement. Background subscription
|
|
166
|
+
renewal requires the workspace page to reopen and reconcile its subscription;
|
|
167
|
+
revoked permission requires an explicit user action in browser/device settings.
|
|
168
|
+
|
|
169
|
+
Acceptance receipts retain event identity and a canonical payload hash even after
|
|
170
|
+
an inbox item is deleted, preventing retry resurrection. Legacy inbox entries are
|
|
171
|
+
admitted as receipts on load. The existing inbox/job caps remain; a separate cap of
|
|
172
|
+
100,000 receipts per account rejects new events explicitly rather than evicting
|
|
173
|
+
dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
|
package/dist/chat-target.js
CHANGED
|
@@ -9,4 +9,7 @@ export function chatTarget(path) {
|
|
|
9
9
|
}
|
|
10
10
|
return null;
|
|
11
11
|
}
|
|
12
|
-
export function chatKey(value) {
|
|
12
|
+
export function chatKey(value) {
|
|
13
|
+
const chat = value.source === 'messenger' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
|
|
14
|
+
return JSON.stringify([value.source, chat]);
|
|
15
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -44,7 +44,7 @@ export function createNotificationService(config, send) {
|
|
|
44
44
|
if (req.headers.origin || req.headers.cookie || req.headers['sec-fetch-site'])
|
|
45
45
|
throw new HttpError(403, 'use authenticated gateway');
|
|
46
46
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
47
|
-
const producerRoute =
|
|
47
|
+
const producerRoute = ['/api/v1/send', '/api/v1/delete-target'].includes(url.pathname);
|
|
48
48
|
const authorization = req.headers.authorization ?? '';
|
|
49
49
|
const credential = (producerRoute ? config.producers : config.users).find(c => same(authorization, `Bearer ${c.token}`));
|
|
50
50
|
if (!credential)
|
|
@@ -61,6 +61,8 @@ export function createNotificationService(config, send) {
|
|
|
61
61
|
let result;
|
|
62
62
|
if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
|
|
63
63
|
result = { publicKey: config.vapid.publicKey };
|
|
64
|
+
else if (req.method === 'GET' && url.pathname === '/api/v1/delivery/status')
|
|
65
|
+
result = store.deliveryStatus(user);
|
|
64
66
|
else if (req.method === 'GET' && url.pathname === '/api/v1/summary')
|
|
65
67
|
result = store.summary(user);
|
|
66
68
|
else if (req.method === 'GET' && url.pathname === '/api/v1/inbox') {
|
|
@@ -88,16 +90,31 @@ export function createNotificationService(config, send) {
|
|
|
88
90
|
store.read(user, ids.map(id => text(id, 100)));
|
|
89
91
|
result = { ok: true };
|
|
90
92
|
}
|
|
91
|
-
else if (req.method === 'POST' &&
|
|
93
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/delete') {
|
|
94
|
+
const value = await body(req), ids = value.ids;
|
|
95
|
+
if ('userId' in value || 'source' in value || !Array.isArray(ids) || ids.length > 100)
|
|
96
|
+
throw new HttpError(400, 'invalid notification ids');
|
|
97
|
+
result = { removed: store.deleteNotifications(user, ids.map(id => text(id, 100))) };
|
|
98
|
+
}
|
|
99
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/delete-target') {
|
|
100
|
+
const value = await body(req);
|
|
101
|
+
if ('userId' in value || 'source' in value)
|
|
102
|
+
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
103
|
+
result = { removed: store.deleteTarget(user, credential.source, target(value.url)) };
|
|
104
|
+
}
|
|
105
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/send') {
|
|
92
106
|
const value = await body(req);
|
|
93
107
|
if ('userId' in value || 'source' in value)
|
|
94
108
|
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
95
|
-
|
|
109
|
+
const taskId = value.taskId === undefined ? undefined : value.taskId === null ? null : text(value.taskId, 120);
|
|
110
|
+
if (typeof taskId === 'string' && !/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
111
|
+
throw new HttpError(400, 'invalid task identity');
|
|
112
|
+
result = store.produce({ ...(taskId === undefined ? {} : { taskId }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
|
|
96
113
|
title: text(value.title, 160), body: text(value.body, 512), url: target(value.url) });
|
|
97
114
|
}
|
|
98
115
|
else
|
|
99
116
|
throw new HttpError(404, 'route not found');
|
|
100
|
-
if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target'].includes(url.pathname))
|
|
117
|
+
if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target', '/api/v1/delete', '/api/v1/delete-target'].includes(url.pathname))
|
|
101
118
|
presence.notify(user);
|
|
102
119
|
res.end(JSON.stringify(result));
|
|
103
120
|
}
|
|
@@ -113,21 +130,21 @@ export function createNotificationService(config, send) {
|
|
|
113
130
|
const drain = () => draining ??= (async () => {
|
|
114
131
|
for (const j of store.due(Date.now())) {
|
|
115
132
|
if (!j.device || !j.notification || j.notification.readAt) {
|
|
116
|
-
store.finish(j.id, 'sent', Date.now());
|
|
133
|
+
store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
|
|
117
134
|
continue;
|
|
118
135
|
}
|
|
119
|
-
if (presence.online(j.device.userId, j.device.id)) {
|
|
120
|
-
store.finish(j.id, 'sent', Date.now());
|
|
136
|
+
if (!/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(new URL(j.notification.url, 'https://ours.invalid').pathname) && presence.online(j.device.userId, j.device.id)) {
|
|
137
|
+
store.finish(j.id, 'sent', Date.now(), 'foreground_suppressed');
|
|
121
138
|
continue;
|
|
122
139
|
}
|
|
123
140
|
try {
|
|
124
|
-
await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
141
|
+
const result = await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
125
142
|
body: j.notification.body, url: j.notification.url, notification_id: j.notification.id, tag: j.notification.id }));
|
|
126
|
-
store.finish(j.id, 'sent', Date.now());
|
|
143
|
+
store.finish(j.id, 'sent', Date.now(), 'provider_accepted', result?.statusCode);
|
|
127
144
|
}
|
|
128
145
|
catch (error) {
|
|
129
146
|
const status = error.statusCode;
|
|
130
|
-
store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now());
|
|
147
|
+
store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now(), status === 404 || status === 410 ? 'endpoint_expired' : 'retry_scheduled', status);
|
|
131
148
|
}
|
|
132
149
|
}
|
|
133
150
|
})().finally(() => { draining = undefined; });
|
package/dist/store.d.ts
CHANGED
|
@@ -10,12 +10,23 @@ export interface Notification {
|
|
|
10
10
|
createdAt: string;
|
|
11
11
|
createdRevision?: number;
|
|
12
12
|
readAt: string | null;
|
|
13
|
+
taskId?: string | null;
|
|
13
14
|
}
|
|
14
15
|
interface Device {
|
|
15
16
|
id: string;
|
|
16
17
|
userId: string;
|
|
17
18
|
subscription: PushSubscription;
|
|
18
19
|
}
|
|
20
|
+
export type DeliveryOutcome = 'provider_accepted' | 'foreground_suppressed' | 'endpoint_expired' | 'retry_scheduled' | 'read_cancelled' | 'missing_target' | 'no_device';
|
|
21
|
+
interface Delivery {
|
|
22
|
+
userId: string;
|
|
23
|
+
notificationId: string;
|
|
24
|
+
deviceId?: string;
|
|
25
|
+
at: string;
|
|
26
|
+
outcome: DeliveryOutcome;
|
|
27
|
+
attempt: number;
|
|
28
|
+
statusCode?: number;
|
|
29
|
+
}
|
|
19
30
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
20
31
|
export declare class Store {
|
|
21
32
|
private readonly file;
|
|
@@ -45,10 +56,21 @@ export declare class Store {
|
|
|
45
56
|
chat: string;
|
|
46
57
|
count: number;
|
|
47
58
|
}[];
|
|
59
|
+
sections: {
|
|
60
|
+
chats: number;
|
|
61
|
+
tasks: number;
|
|
62
|
+
};
|
|
48
63
|
};
|
|
49
64
|
readTarget(userId: string, url: string, revision: number): void;
|
|
50
65
|
read(userId: string, ids: string[]): void;
|
|
51
|
-
|
|
66
|
+
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
67
|
+
deleteNotifications(userId: string, ids: string[]): number;
|
|
68
|
+
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
69
|
+
deleteTarget(userId: string, source: string, url: string): number;
|
|
70
|
+
produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification | {
|
|
71
|
+
id: string;
|
|
72
|
+
duplicate: true;
|
|
73
|
+
};
|
|
52
74
|
due(now: number): {
|
|
53
75
|
device: Device | undefined;
|
|
54
76
|
notification: Notification | undefined;
|
|
@@ -58,6 +80,13 @@ export declare class Store {
|
|
|
58
80
|
attempts: number;
|
|
59
81
|
due: number;
|
|
60
82
|
}[];
|
|
61
|
-
|
|
83
|
+
/** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
|
|
84
|
+
deliveryStatus(userId: string): {
|
|
85
|
+
devices: number;
|
|
86
|
+
pending: number;
|
|
87
|
+
recent: Delivery[];
|
|
88
|
+
};
|
|
89
|
+
private recordDelivery;
|
|
90
|
+
finish(id: string, status: 'sent' | 'gone' | 'retry', now: number, outcome?: DeliveryOutcome, statusCode?: number): void;
|
|
62
91
|
}
|
|
63
92
|
export {};
|
package/dist/store.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { chatTarget, chatKey } from './chat-target.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import { HttpError } from './validation.js';
|
|
6
|
+
const fingerprint = (value) => createHash('sha256')
|
|
7
|
+
.update(JSON.stringify([value.userId, value.source, value.eventId, value.title, value.body, value.url, value.taskId === undefined ? [] : [value.taskId]])).digest('hex');
|
|
6
8
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
7
9
|
export class Store {
|
|
8
10
|
file;
|
|
@@ -29,6 +31,8 @@ export class Store {
|
|
|
29
31
|
}
|
|
30
32
|
try {
|
|
31
33
|
this.state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { version: 1, revision: 0, devices: [], notifications: [], jobs: [] };
|
|
34
|
+
this.state.receipts ??= this.state.notifications.map(n => ({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) }));
|
|
35
|
+
this.state.deliveries ??= [];
|
|
32
36
|
if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
|
|
33
37
|
throw new Error('invalid notification state');
|
|
34
38
|
}
|
|
@@ -104,7 +108,20 @@ export class Store {
|
|
|
104
108
|
summary(userId) {
|
|
105
109
|
const unread = this.state.notifications.filter(n => n.userId === userId && !n.readAt);
|
|
106
110
|
const groups = new Map();
|
|
111
|
+
const sections = { chats: 0, tasks: 0 };
|
|
107
112
|
for (const n of unread) {
|
|
113
|
+
const url = new URL(n.url, 'https://ours.invalid'), targetForSection = chatTarget(n.url);
|
|
114
|
+
if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
|
|
115
|
+
sections.tasks++;
|
|
116
|
+
else if (targetForSection) {
|
|
117
|
+
if (typeof n.taskId === 'string' || targetForSection.source === 'work' && /^room-[^:]+(?::[^:]+)?$/.test(targetForSection.chat))
|
|
118
|
+
sections.tasks++;
|
|
119
|
+
else if (targetForSection.source === 'messenger')
|
|
120
|
+
sections.chats++;
|
|
121
|
+
else if (n.taskId === null)
|
|
122
|
+
sections.chats++;
|
|
123
|
+
// Older ACP records carry no association: leave them in the total only.
|
|
124
|
+
}
|
|
108
125
|
const target = chatTarget(n.url);
|
|
109
126
|
if (!target)
|
|
110
127
|
continue;
|
|
@@ -112,7 +129,7 @@ export class Store {
|
|
|
112
129
|
group.count++;
|
|
113
130
|
groups.set(key, group);
|
|
114
131
|
}
|
|
115
|
-
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()] };
|
|
132
|
+
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()], sections };
|
|
116
133
|
}
|
|
117
134
|
readTarget(userId, url, revision) {
|
|
118
135
|
if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
|
|
@@ -138,19 +155,51 @@ export class Store {
|
|
|
138
155
|
s.jobs = s.jobs.filter(j => !s.notifications.find(n => n.id === j.notificationId)?.readAt);
|
|
139
156
|
});
|
|
140
157
|
}
|
|
158
|
+
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
159
|
+
deleteNotifications(userId, ids) {
|
|
160
|
+
const chosen = new Set(ids);
|
|
161
|
+
const removed = new Set(this.state.notifications.filter(n => n.userId === userId && chosen.has(n.id)).map(n => n.id));
|
|
162
|
+
if (!removed.size)
|
|
163
|
+
return 0;
|
|
164
|
+
this.commit(s => {
|
|
165
|
+
s.notifications = s.notifications.filter(n => !removed.has(n.id));
|
|
166
|
+
s.jobs = s.jobs.filter(j => !removed.has(j.notificationId));
|
|
167
|
+
});
|
|
168
|
+
return removed.size;
|
|
169
|
+
}
|
|
170
|
+
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
171
|
+
deleteTarget(userId, source, url) {
|
|
172
|
+
const target = chatTarget(url);
|
|
173
|
+
if (!target)
|
|
174
|
+
throw new HttpError(400, 'expected chat target');
|
|
175
|
+
const key = chatKey(target);
|
|
176
|
+
const ids = this.state.notifications.filter(n => n.userId === userId && n.source === source && (() => {
|
|
177
|
+
const other = chatTarget(n.url);
|
|
178
|
+
return other && chatKey(other) === key;
|
|
179
|
+
})()).map(n => n.id);
|
|
180
|
+
return this.deleteNotifications(userId, ids);
|
|
181
|
+
}
|
|
141
182
|
produce(value) {
|
|
183
|
+
const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
|
|
184
|
+
if (receipt && receipt.fingerprint !== fingerprint(value))
|
|
185
|
+
throw new HttpError(409, 'event id reused');
|
|
142
186
|
const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
|
|
143
187
|
if (previous) {
|
|
144
|
-
if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url)
|
|
188
|
+
if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url || previous.taskId !== value.taskId)
|
|
145
189
|
throw new HttpError(409, 'event id reused');
|
|
146
190
|
return previous;
|
|
147
191
|
}
|
|
192
|
+
if (receipt)
|
|
193
|
+
return { id: receipt.id, duplicate: true };
|
|
148
194
|
// Explicit capacity failure instead of silently evicting unread history or dedupe records.
|
|
149
|
-
if (this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
|
|
195
|
+
if (this.state.receipts.filter(r => r.userId === value.userId).length >= 100_000 || this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
|
|
150
196
|
throw new HttpError(503, 'notification capacity exceeded');
|
|
151
197
|
const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1, readAt: null };
|
|
152
198
|
this.commit(s => {
|
|
153
199
|
s.notifications.push(n);
|
|
200
|
+
s.receipts.push({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) });
|
|
201
|
+
if (!s.devices.some(d => d.userId === n.userId))
|
|
202
|
+
this.recordDelivery(s, { userId: n.userId, notificationId: n.id, at: n.createdAt, outcome: 'no_device', attempt: 0 });
|
|
154
203
|
for (const d of s.devices)
|
|
155
204
|
if (d.userId === n.userId)
|
|
156
205
|
s.jobs.push({ id: randomUUID(), notificationId: n.id, deviceId: d.id, attempts: 0, due: Date.now() });
|
|
@@ -161,20 +210,35 @@ export class Store {
|
|
|
161
210
|
return this.state.jobs.filter(j => j.due <= now).slice(0, 32).map(j => ({ ...j,
|
|
162
211
|
device: this.state.devices.find(d => d.id === j.deviceId), notification: this.state.notifications.find(n => n.id === j.notificationId) }));
|
|
163
212
|
}
|
|
164
|
-
|
|
213
|
+
/** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
|
|
214
|
+
deliveryStatus(userId) {
|
|
215
|
+
return { devices: this.state.devices.filter(d => d.userId === userId).length,
|
|
216
|
+
pending: this.state.jobs.filter(j => this.state.notifications.some(n => n.id === j.notificationId && n.userId === userId)).length,
|
|
217
|
+
recent: this.state.deliveries.filter(d => d.userId === userId).slice(-100).reverse() };
|
|
218
|
+
}
|
|
219
|
+
recordDelivery(state, value) {
|
|
220
|
+
const sameUser = state.deliveries.filter(d => d.userId === value.userId).slice(-999);
|
|
221
|
+
state.deliveries = [...state.deliveries.filter(d => d.userId !== value.userId), ...sameUser, value];
|
|
222
|
+
}
|
|
223
|
+
finish(id, status, now, outcome = status === 'sent' ? 'provider_accepted' : status === 'gone' ? 'endpoint_expired' : 'retry_scheduled', statusCode) {
|
|
165
224
|
this.commit(s => {
|
|
166
225
|
const j = s.jobs.find(j => j.id === id);
|
|
167
226
|
if (!j)
|
|
168
227
|
return;
|
|
228
|
+
const n = s.notifications.find(n => n.id === j.notificationId);
|
|
229
|
+
if (n)
|
|
230
|
+
this.recordDelivery(s, { userId: n.userId, notificationId: n.id, deviceId: j.deviceId,
|
|
231
|
+
at: new Date(now).toISOString(), outcome, attempt: j.attempts + 1,
|
|
232
|
+
...(Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? { statusCode } : {}) });
|
|
169
233
|
if (status === 'gone') {
|
|
170
234
|
s.devices = s.devices.filter(d => d.id !== j.deviceId);
|
|
171
235
|
s.jobs = s.jobs.filter(v => v.deviceId !== j.deviceId);
|
|
172
236
|
}
|
|
173
|
-
else if (status === 'sent'
|
|
237
|
+
else if (status === 'sent')
|
|
174
238
|
s.jobs = s.jobs.filter(v => v.id !== id);
|
|
175
239
|
else {
|
|
176
|
-
j.attempts
|
|
177
|
-
j.due = now + Math.min(60_000 * 2 ** j.attempts, 3_600_000);
|
|
240
|
+
j.attempts = Math.min(j.attempts + 1, 1024);
|
|
241
|
+
j.due = now + Math.min(60_000 * 2 ** Math.min(j.attempts, 6), 3_600_000);
|
|
178
242
|
}
|
|
179
243
|
});
|
|
180
244
|
}
|
package/package.json
CHANGED