OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 4608995

Add multi-channel notification system spec
Five delivery channels (all first-class, not fallback): - In-app P2P (Hyperbee + SSE, zero deps) - Mobile push (BareKit APNs/FCM, already in Holepunch) - Email (self-hosted Mox/Postal, x402mail, or SES) - SMS (USB GSM modem via serialport-gsm, or Telnyx API) - WebPush (Service Worker + VAPID, zero cost) Includes notification manager, scheduler, .ics generation, client preferences, and x402 SMS gateway concept. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4608995cffb732cdccc257f51f388bc4257ca230
by plankatron <fleeky@gmail.com> · 2026-03-22T15:14:00.000Z

Changed files (1) +741 -0

docs/design/v1-notifications.md +741 -0
……@@ -0,0 +1,741 @@
1# hyper-booking v1 — Notification System Spec
2
3Multi-channel notification delivery. Not P2P-first with fallbacks —
4P2P, push, email, and SMS are all first-class delivery channels.
5
6---
7
8## Design Principles
9
101. **Multi-channel, not fallback** — every channel is equal, user picks preference
112. **Delivery guarantee** — escalate if primary channel fails (configurable)
123. **Provider controls defaults** — which channels are enabled, reminder timing
134. **Client controls opt-in** — which channels they receive, per notification type
145. **Self-hostable everything** — no mandatory SaaS dependencies
156. **Offline-resilient** — notifications queue and deliver when recipient comes online
16
17---
18
19## Notification Channels
20
21### Channel 1: In-App (P2P)
22
23Real-time notifications via Hypercore replication + Keet-daemon rooms.
24
25**How it works:**
26- Each provider-client relationship gets a Keet room (or Hyperswarm topic)
27- Notifications appended to Hyperbee feed
28- Client portal polls `/api/notifications` or streams via SSE (`/api/notifications/stream`)
29- Keet-daemon `watch()` provides real-time SSE delivery
30- Works fully offline — notifications sync when peers reconnect
31
32**Implementation:**
33```javascript
34// Server side — append notification
35await notificationDb.put(`notif:${clientId}:${Date.now()}`, {
36 type: 'booking-confirmed',
37 bookingId: 'bk_abc',
38 message: 'Your session is confirmed for April 10 at 2pm',
39 read: false,
40 createdAt: Date.now()
41})
42
43// Client side — SSE stream
44const stream = new EventSource('/api/notifications/stream')
45stream.onmessage = (e) => {
46 const notif = JSON.parse(e.data)
47 showToast(notif.message)
48 updateBadge()
49}
50```
51
52**Storage:**
53```
54notification:{clientId}:{timestamp} → notification object
55notification-prefs:{clientId} → channel preferences
56```
57
58**Cost:** Zero
59**Dependencies:** None (built on Hyperbee + HTTP SSE)
60
61---
62
63### Channel 2: Mobile Push (iOS + Android)
64
65Native push notifications via BareKit — already implemented in Holepunchto.
66
67**How it works:**
68- iOS: APNs via `BareKit.NotificationService` (Swift extension)
69- Android: FCM via `BareKit.MessagingService` (Kotlin)
70- Both trigger `BareKit.on('push')` in JavaScript
71- JS handler (`push.js`) formats the notification for native display
72
73**Architecture:**
74```
75hyper-booking server
76 → Push service (APNs / FCM)
77 → Native OS notification
78 → BareKit.on('push') → push.js handler
79 → Native notification render (title, body, action)
80```
81
82**Implementation (push.js):**
83```javascript
84// Runs in BareKit notification extension
85BareKit.on('push', (payload, reply) => {
86 const { type, message, bookingId } = payload
87
88 reply({
89 title: formatTitle(type),
90 body: message,
91 data: { bookingId, action: 'open-booking' }
92 })
93})
94
95function formatTitle (type) {
96 switch (type) {
97 case 'booking-confirmed': return 'Booking Confirmed'
98 case 'reminder-24h': return 'Session Tomorrow'
99 case 'reminder-1h': return 'Session in 1 Hour'
100 case 'cancelled': return 'Session Cancelled'
101 case 'rescheduled': return 'Session Rescheduled'
102 default: return 'Booking Update'
103 }
104}
105```
106
107**Server-side push sender:**
108```javascript
109// lib/notifications/push.js
110const apn = require('apn') // or bare-apn equivalent
111
112class PushChannel {
113 async send (deviceToken, platform, notification) {
114 if (platform === 'ios') {
115 return this.sendAPNs(deviceToken, notification)
116 }
117 if (platform === 'android') {
118 return this.sendFCM(deviceToken, notification)
119 }
120 }
121
122 async sendAPNs (token, notif) {
123 const note = new apn.Notification({
124 alert: { title: notif.title, body: notif.message },
125 topic: 'com.hyper-booking.app',
126 payload: { bookingId: notif.bookingId, type: notif.type }
127 })
128 return this.apnProvider.send(note, token)
129 }
130
131 async sendFCM (token, notif) {
132 return fetch('https://fcm.googleapis.com/v1/projects/.../messages:send', {
133 method: 'POST',
134 headers: { Authorization: `Bearer ${this.fcmToken}` },
135 body: JSON.stringify({
136 message: {
137 token,
138 notification: { title: notif.title, body: notif.message },
139 data: { bookingId: notif.bookingId, type: notif.type }
140 }
141 })
142 })
143 }
144}
145```
146
147**Cost:** Free (APNs free, FCM free tier generous)
148**Dependencies:** Apple Developer account, Firebase project (for FCM tokens only)
149
150---
151
152### Channel 3: Email (Self-Hosted)
153
154Transactional email via self-hosted mail server. No SaaS required.
155
156**Option A: Self-hosted Mox/Postal**
157- Run Mox (Go) or Postal (Ruby) on your own server
158- Full control, proven deliverability for transactional email
159- Requires: domain with SPF/DKIM/DMARC, clean IP, reverse DNS
160- Bonus: Delta Chat users see emails as chat messages automatically
161
162**Option B: x402mail (pay-per-message, no accounts)**
163- REST API, pay ~$0.005/email in USDC micropayment
164- No API keys, no accounts, no monthly bills
165- Uses hyper-pay's x402 rail
166- Built-in AI anti-spam
167
168**Option C: Minimal SaaS (if self-hosting is too much for v1)**
169- Amazon SES: $0.10/1000 emails
170- Resend: free tier 3000 emails/month
171- Postmark: $1.25/1000 emails
172
173**Implementation:**
174```javascript
175// lib/notifications/email.js
176class EmailChannel {
177 constructor (config) {
178 // config.provider: 'smtp' | 'x402' | 'ses' | 'resend'
179 this.transport = this.createTransport(config)
180 }
181
182 async send (to, notification) {
183 const { subject, html, text } = this.renderTemplate(notification)
184
185 return this.transport.send({
186 from: `${notification.providerName} via HyperBooking <bookings@${this.domain}>`,
187 to,
188 subject,
189 html,
190 text,
191 attachments: notification.icsAttachment
192 ? [{ filename: 'invite.ics', content: notification.icsAttachment }]
193 : []
194 })
195 }
196
197 renderTemplate (notif) {
198 switch (notif.type) {
199 case 'booking-confirmed':
200 return {
201 subject: `Booking Confirmed — ${notif.providerName}`,
202 html: confirmationTemplate(notif),
203 text: `Your session with ${notif.providerName} is confirmed for ${notif.dateTime}`
204 }
205 case 'reminder-24h':
206 return {
207 subject: `Reminder: Session Tomorrow with ${notif.providerName}`,
208 html: reminderTemplate(notif),
209 text: `Reminder: Your session is tomorrow at ${notif.time}`
210 }
211 // ... other types
212 }
213 }
214}
215```
216
217**Email types:**
218| Email | Trigger | Includes |
219|-------|---------|----------|
220| Booking confirmation | After successful checkout | .ics calendar invite |
221| 24h reminder | 24 hours before session | Session details, reschedule link |
222| 1h reminder | 1 hour before session | Quick info, join link if virtual |
223| Cancellation | Client or provider cancels | Refund details if applicable |
224| Reschedule | Session time changed | New time, calendar update |
225| Purchase receipt | Payment processed | Amount, package details, invoice |
226| Subscription renewal | Recurring charge | Amount, next billing date |
227| Intake reminder | 48h before first session (form not completed) | Link to form |
228
229**Cost:** $0 (self-hosted) to $0.005/email (x402) to ~$0.001/email (SES)
230**Dependencies:** Domain + DNS (self-hosted) or REST API (x402/SaaS)
231
232---
233
234### Channel 4: SMS
235
236Text messages for critical notifications. Multiple backend options.
237
238**Option A: USB GSM Modem (fully self-hosted)**
239- Hardware: Quectel EC25 USB dongle (~$40) + prepaid SIM
240- Software: `serialport-gsm` npm package (AT commands over serial)
241- Daemon: `gammu-smsd` for queue management, or integrate directly in Node.js
242- No internet required for SMS leg
243- No API keys, no accounts, no monthly API fees
244
245**Option B: VoIP Provider API**
246- Telnyx: $0.004/message, REST API
247- VoIP.ms: $0.0075/message, REST API
248- No contracts, pay-per-use
249
250**Option C: x402 SMS Gateway (doesn't exist yet — we could build it)**
251- Same pattern as x402mail but for SMS
252- REST API, pay per message in USDC micropayment
253- No accounts needed, open protocol
254- Could be a standalone hyper-pay service
255
256**Option D: KDE Connect (dev/testing only)**
257- Route SMS through connected Android phone on LAN
258- `kdeconnect-cli --send-sms "message" --destination "+1234567890"`
259- Free, no external dependencies
260
261**Implementation (Option A — USB Modem):**
262```javascript
263// lib/notifications/sms.js
264const SerialPortGSM = require('serialport-gsm')
265
266class SMSChannel {
267 constructor (config) {
268 this.modem = SerialPortGSM.Modem()
269 this.port = config.port || '/dev/ttyUSB0'
270 }
271
272 async init () {
273 return new Promise((resolve, reject) => {
274 this.modem.open(this.port, { baudRate: 115200 }, (err) => {
275 if (err) return reject(err)
276 this.modem.initializeModem((err) => {
277 if (err) return reject(err)
278 resolve()
279 })
280 })
281 })
282 }
283
284 async send (phoneNumber, notification) {
285 const text = this.formatSMS(notification)
286
287 return new Promise((resolve, reject) => {
288 this.modem.sendSMS(phoneNumber, text, false, (err, result) => {
289 if (err) return reject(err)
290 resolve(result)
291 })
292 })
293 }
294
295 formatSMS (notif) {
296 switch (notif.type) {
297 case 'reminder-24h':
298 return `Reminder: Your session with ${notif.providerName} is tomorrow at ${notif.time}. To reschedule: ${notif.rescheduleUrl}`
299 case 'reminder-1h':
300 return `Your session with ${notif.providerName} starts in 1 hour!`
301 case 'cancelled':
302 return `Your session with ${notif.providerName} has been cancelled.`
303 default:
304 return notif.message
305 }
306 }
307}
308```
309
310**Implementation (Option B — API):**
311```javascript
312// lib/notifications/sms-api.js
313class SMSAPIChannel {
314 constructor (config) {
315 // config.provider: 'telnyx' | 'voipms'
316 this.apiKey = config.apiKey
317 this.from = config.fromNumber
318 }
319
320 async send (phoneNumber, notification) {
321 const text = this.formatSMS(notification)
322
323 if (this.provider === 'telnyx') {
324 return fetch('https://api.telnyx.com/v2/messages', {
325 method: 'POST',
326 headers: {
327 Authorization: `Bearer ${this.apiKey}`,
328 'Content-Type': 'application/json'
329 },
330 body: JSON.stringify({
331 from: this.from,
332 to: phoneNumber,
333 text
334 })
335 })
336 }
337 }
338}
339```
340
341**SMS types (minimal — only critical):**
342| SMS | Trigger |
343|-----|---------|
344| Session reminder (24h) | 24 hours before |
345| Session reminder (1h) | 1 hour before |
346| Cancellation alert | Session cancelled |
347| Reschedule alert | Time changed |
348
349**Cost:** $0 (USB modem + SIM plan) to $0.004/msg (Telnyx)
350**Dependencies:** USB modem (Option A) or API account (Option B)
351
352---
353
354### Channel 5: WebPush (Browser)
355
356Browser push notifications via Service Worker + VAPID keys.
357
358**How it works:**
359- Client grants notification permission in browser
360- Service Worker registered, gets push subscription endpoint
361- Server sends encrypted push via browser vendor service (Google/Mozilla/Apple)
362- Notification displays even when tab is closed
363
364**Implementation:**
365```javascript
366// Server — register subscription
367app.post('/api/notifications/webpush/subscribe', async (req, res) => {
368 const { subscription } = req.body // PushSubscription from browser
369 await notificationDb.put(`webpush:${req.auth.userId}`, subscription)
370 res.json({ ok: true })
371})
372
373// Server — send push
374const webpush = require('web-push')
375webpush.setVapidDetails('mailto:admin@hyper-booking.example', publicKey, privateKey)
376
377async function sendWebPush (userId, notification) {
378 const sub = await notificationDb.get(`webpush:${userId}`)
379 if (!sub) return
380
381 await webpush.sendNotification(sub.value, JSON.stringify({
382 title: notification.title,
383 body: notification.message,
384 data: { bookingId: notification.bookingId, url: notification.actionUrl }
385 }))
386}
387
388// Client — service-worker.js
389self.addEventListener('push', (event) => {
390 const data = event.data.json()
391 event.waitUntil(
392 self.registration.showNotification(data.title, {
393 body: data.body,
394 data: data.data,
395 icon: '/icons/notification-192.png'
396 })
397 )
398})
399
400self.addEventListener('notificationclick', (event) => {
401 event.notification.close()
402 event.waitUntil(clients.openWindow(event.notification.data.url))
403})
404```
405
406**Cost:** Zero (VAPID is free, browser push services are free)
407**Dependencies:** Browser with push support (all modern browsers)
408
409---
410
411## Notification Manager
412
413Central coordinator that dispatches to all enabled channels.
414
415```javascript
416// lib/notifications/manager.js
417class NotificationManager {
418 constructor (config) {
419 this.channels = {}
420
421 // Initialize enabled channels
422 if (config.inApp !== false) this.channels.inApp = new InAppChannel(config.db)
423 if (config.push) this.channels.push = new PushChannel(config.push)
424 if (config.email) this.channels.email = new EmailChannel(config.email)
425 if (config.sms) this.channels.sms = new SMSChannel(config.sms)
426 if (config.webpush) this.channels.webpush = new WebPushChannel(config.webpush)
427
428 // Scheduler for timed notifications (reminders)
429 this.scheduler = new NotificationScheduler(this)
430 }
431
432 async notify (clientId, notification) {
433 // Get client's channel preferences
434 const prefs = await this.getClientPrefs(clientId)
435
436 // Get notification type config (which channels for this type)
437 const typeConfig = this.getTypeConfig(notification.type)
438
439 // Dispatch to all enabled + preferred channels
440 const results = {}
441
442 for (const channel of typeConfig.channels) {
443 if (!this.channels[channel]) continue
444 if (!prefs.enabled.includes(channel)) continue
445
446 try {
447 results[channel] = await this.channels[channel].send(
448 this.getDestination(clientId, channel),
449 notification
450 )
451 } catch (err) {
452 results[channel] = { error: err.message }
453
454 // Escalate to next channel if delivery fails
455 if (typeConfig.escalate && typeConfig.escalateOrder) {
456 const nextChannel = this.getNextChannel(channel, typeConfig.escalateOrder)
457 if (nextChannel && this.channels[nextChannel]) {
458 results[nextChannel] = await this.channels[nextChannel].send(
459 this.getDestination(clientId, nextChannel),
460 notification
461 )
462 }
463 }
464 }
465 }
466
467 // Log delivery results
468 await this.logDelivery(clientId, notification, results)
469 return results
470 }
471
472 // Schedule future notification (e.g., 24h reminder)
473 async scheduleReminder (booking, reminderType, beforeMs) {
474 const triggerAt = booking.startTime - beforeMs
475
476 await this.scheduler.schedule({
477 triggerAt,
478 clientId: booking.clientId,
479 notification: {
480 type: reminderType,
481 bookingId: booking.id,
482 providerName: booking.providerName,
483 dateTime: new Date(booking.startTime).toISOString(),
484 time: new Date(booking.startTime).toLocaleTimeString()
485 }
486 })
487 }
488}
489```
490
491---
492
493## Notification Type Configuration
494
495Which channels fire for each notification type:
496
497```javascript
498const NOTIFICATION_TYPES = {
499 'booking-confirmed': {
500 channels: ['inApp', 'email', 'push', 'webpush'],
501 escalate: false,
502 // Always send to all enabled channels
503 },
504 'reminder-24h': {
505 channels: ['inApp', 'email', 'push', 'sms', 'webpush'],
506 escalate: true,
507 escalateOrder: ['push', 'webpush', 'email', 'sms'],
508 // Try push first, escalate if not delivered
509 },
510 'reminder-1h': {
511 channels: ['inApp', 'push', 'sms', 'webpush'],
512 escalate: true,
513 escalateOrder: ['push', 'sms'],
514 // Critical — push, then SMS if needed
515 },
516 'cancelled': {
517 channels: ['inApp', 'email', 'push', 'sms', 'webpush'],
518 escalate: false,
519 // Blast all channels
520 },
521 'rescheduled': {
522 channels: ['inApp', 'email', 'push', 'webpush'],
523 escalate: false,
524 },
525 'purchase-receipt': {
526 channels: ['inApp', 'email'],
527 escalate: false,
528 },
529 'intake-reminder': {
530 channels: ['inApp', 'email'],
531 escalate: false,
532 },
533 'subscription-renewal': {
534 channels: ['inApp', 'email'],
535 escalate: false,
536 }
537}
538```
539
540---
541
542## Client Notification Preferences
543
544```javascript
545{
546 clientId: 'cli_abc',
547 enabled: ['inApp', 'email', 'push', 'webpush'], // which channels active
548 destinations: {
549 email: 'jane@example.com',
550 sms: '+1234567890',
551 push: { token: 'device_token', platform: 'ios' },
552 webpush: { endpoint: '...', keys: { p256dh: '...', auth: '...' } }
553 },
554 quiet: {
555 start: '22:00', // no notifications after 10pm
556 end: '07:00', // until 7am
557 timezone: 'America/New_York'
558 },
559 perType: {
560 'reminder-1h': { sms: true }, // opt-in to SMS for 1h reminders
561 'purchase-receipt': { sms: false } // no SMS for receipts
562 }
563}
564```
565
566**Storage:** `notification-prefs:{clientId}` → preferences object
567
568---
569
570## Notification Scheduler
571
572Handles timed notifications (reminders before sessions).
573
574```javascript
575// lib/notifications/scheduler.js
576class NotificationScheduler {
577 constructor (manager) {
578 this.manager = manager
579 this.db = manager.channels.inApp.db
580 this.timers = new Map()
581 }
582
583 async schedule (job) {
584 // Persist to Hyperbee (survives restart)
585 await this.db.put(`scheduled:${job.triggerAt}:${job.notification.bookingId}`, job)
586
587 // Set in-memory timer
588 const delay = job.triggerAt - Date.now()
589 if (delay > 0 && delay < 86400000) { // only set timer if within 24h
590 const timer = setTimeout(() => this.fire(job), delay)
591 this.timers.set(job.notification.bookingId + ':' + job.notification.type, timer)
592 }
593 }
594
595 async fire (job) {
596 await this.manager.notify(job.clientId, job.notification)
597 await this.db.del(`scheduled:${job.triggerAt}:${job.notification.bookingId}`)
598 }
599
600 // On startup: load all pending scheduled notifications
601 async restore () {
602 const now = Date.now()
603 for await (const { key, value: job } of this.db.createReadStream({
604 gte: `scheduled:${now}`,
605 lte: `scheduled:${now + 86400000}` // next 24h
606 })) {
607 const delay = job.triggerAt - now
608 if (delay <= 0) {
609 await this.fire(job) // overdue — fire immediately
610 } else {
611 const timer = setTimeout(() => this.fire(job), delay)
612 this.timers.set(job.notification.bookingId + ':' + job.notification.type, timer)
613 }
614 }
615 }
616
617 async cancel (bookingId) {
618 // Cancel all scheduled notifications for a booking (on cancellation/reschedule)
619 for (const [key, timer] of this.timers) {
620 if (key.startsWith(bookingId + ':')) {
621 clearTimeout(timer)
622 this.timers.delete(key)
623 }
624 }
625 // Clean from Hyperbee
626 for await (const { key } of this.db.createReadStream({
627 gte: `scheduled:0:${bookingId}`,
628 lte: `scheduled:~:${bookingId}`
629 })) {
630 await this.db.del(key)
631 }
632 }
633}
634```
635
636---
637
638## Calendar Invite (.ics) Generation
639
640Attached to email confirmations and reschedule notices.
641
642```javascript
643// lib/notifications/ics.js
644function generateICS (booking) {
645 const start = formatICSDate(booking.startTime)
646 const end = formatICSDate(booking.endTime)
647 const uid = `${booking.id}@hyper-booking`
648
649 return [
650 'BEGIN:VCALENDAR',
651 'VERSION:2.0',
652 'PRODID:-//hyper-booking//EN',
653 'METHOD:REQUEST',
654 'BEGIN:VEVENT',
655 `UID:${uid}`,
656 `DTSTART:${start}`,
657 `DTEND:${end}`,
658 `SUMMARY:${booking.packageName} with ${booking.providerName}`,
659 `DESCRIPTION:${booking.notes || ''}`,
660 `ORGANIZER:MAILTO:${booking.providerEmail}`,
661 `ATTENDEE:MAILTO:${booking.clientEmail}`,
662 'STATUS:CONFIRMED',
663 `LAST-MODIFIED:${formatICSDate(Date.now())}`,
664 'END:VEVENT',
665 'END:VCALENDAR'
666 ].join('\r\n')
667}
668
669function formatICSDate (ts) {
670 return new Date(ts).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
671}
672```
673
674---
675
676## x402 SMS Gateway — New Service Opportunity
677
678No x402-powered SMS gateway exists. We could build one as a standalone hyper-pay service.
679
680**Concept:**
681```
682POST https://sms.hyper-pay.example/send
683 → HTTP 402 Payment Required
684 → Client pays $0.01 USDC via x402
685 → SMS delivered via USB modem pool or VoIP API
686 → HTTP 200 { delivered: true, messageId: '...' }
687```
688
689**Architecture:**
690```
691x402-sms-gateway/
692├── lib/
693│ ├── x402-middleware.js # x402 payment verification
694│ ├── modem-pool.js # USB modem management (gammu/serialport-gsm)
695│ ├── api-fallback.js # Telnyx/VoIP.ms fallback
696│ └── routes.js # POST /send, GET /status/:id
697├── index.js # bare/node entry
698└── package.json
699```
700
701**Revenue model:**
702- Charge $0.01/SMS via x402 micropayment
703- Cost: $0.004 (Telnyx) or ~$0 (USB modem)
704- Margin: $0.006/message
705- No accounts, no API keys for callers — just pay and send
706
707This could serve hyper-booking, hyper-wallet, and any external app.
708
709---
710
711## Implementation Phases
712
713### Phase 5a: In-App + WebPush (v1)
714- Hyperbee notification feed
715- SSE streaming endpoint
716- WebPush via Service Worker + VAPID
717- Toast UI in client portal + provider dashboard
718- Notification preferences API
719
720### Phase 5b: Email (v1)
721- Self-hosted Mox/Postal OR x402mail OR SES
722- Email templates (confirmation, reminder, cancellation)
723- .ics calendar invite generation
724- Unsubscribe handling
725
726### Phase 5c: Mobile Push (v1.1)
727- BareKit push integration (iOS APNs + Android FCM)
728- Push registration endpoint
729- Device token management
730
731### Phase 5d: SMS (v1.1)
732- USB modem integration via serialport-gsm
733- OR Telnyx/VoIP.ms API integration
734- SMS opt-in at checkout
735- Critical reminders only (24h, 1h, cancellation)
736
737### Phase 5e: x402 SMS Gateway (v2 — standalone service)
738- Build as separate hyper-pay service
739- x402 payment middleware
740- Modem pool or API backend
741- Open to any caller (not just hyper-booking)