| … | … | @@ -0,0 +1,741 @@ |
| 1 | # hyper-booking v1 — Notification System Spec |
| 2 | |
| 3 | Multi-channel notification delivery. Not P2P-first with fallbacks — |
| 4 | P2P, push, email, and SMS are all first-class delivery channels. |
| 5 | |
| 6 | --- |
| 7 | |
| 8 | ## Design Principles |
| 9 | |
| 10 | 1. **Multi-channel, not fallback** — every channel is equal, user picks preference |
| 11 | 2. **Delivery guarantee** — escalate if primary channel fails (configurable) |
| 12 | 3. **Provider controls defaults** — which channels are enabled, reminder timing |
| 13 | 4. **Client controls opt-in** — which channels they receive, per notification type |
| 14 | 5. **Self-hostable everything** — no mandatory SaaS dependencies |
| 15 | 6. **Offline-resilient** — notifications queue and deliver when recipient comes online |
| 16 | |
| 17 | --- |
| 18 | |
| 19 | ## Notification Channels |
| 20 | |
| 21 | ### Channel 1: In-App (P2P) |
| 22 | |
| 23 | Real-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 |
| 35 | await 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 |
| 44 | const stream = new EventSource('/api/notifications/stream') |
| 45 | stream.onmessage = (e) => { |
| 46 | const notif = JSON.parse(e.data) |
| 47 | showToast(notif.message) |
| 48 | updateBadge() |
| 49 | } |
| 50 | ``` |
| 51 | |
| 52 | **Storage:** |
| 53 | ``` |
| 54 | notification:{clientId}:{timestamp} → notification object |
| 55 | notification-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 | |
| 65 | Native 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 | ``` |
| 75 | hyper-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 |
| 85 | BareKit.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 | |
| 95 | function 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 |
| 110 | const apn = require('apn') // or bare-apn equivalent |
| 111 | |
| 112 | class 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 | |
| 154 | Transactional 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 |
| 176 | class 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 | |
| 236 | Text 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 |
| 264 | const SerialPortGSM = require('serialport-gsm') |
| 265 | |
| 266 | class 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 |
| 313 | class 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 | |
| 356 | Browser 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 |
| 367 | app.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 |
| 374 | const webpush = require('web-push') |
| 375 | webpush.setVapidDetails('mailto:admin@hyper-booking.example', publicKey, privateKey) |
| 376 | |
| 377 | async 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 |
| 389 | self.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 | |
| 400 | self.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 | |
| 413 | Central coordinator that dispatches to all enabled channels. |
| 414 | |
| 415 | ```javascript |
| 416 | // lib/notifications/manager.js |
| 417 | class 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 | |
| 495 | Which channels fire for each notification type: |
| 496 | |
| 497 | ```javascript |
| 498 | const 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 | |
| 572 | Handles timed notifications (reminders before sessions). |
| 573 | |
| 574 | ```javascript |
| 575 | // lib/notifications/scheduler.js |
| 576 | class 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 | |
| 640 | Attached to email confirmations and reschedule notices. |
| 641 | |
| 642 | ```javascript |
| 643 | // lib/notifications/ics.js |
| 644 | function 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 | |
| 669 | function 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 | |
| 678 | No x402-powered SMS gateway exists. We could build one as a standalone hyper-pay service. |
| 679 | |
| 680 | **Concept:** |
| 681 | ``` |
| 682 | POST 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 | ``` |
| 691 | x402-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 | |
| 707 | This 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) |