package main import ( "database/sql" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "sort" "time" ) // Reminders are evaluated on the server because that is the only place that can // act while every client is closed — a PWA gets no timers of its own once the // tab is gone. The event log is already here (clients sync on every mutation), // so a rule is just a query over it plus a push. // // Two shapes of rule, both keyed off the same synced events: // // - "sleep": the puppy has been awake too long. Fires only while awake, so it // is silent overnight by construction. // - "pee" / "poo" / "eat": nothing of that type logged for too long. Suppressed // while the puppy is asleep — otherwise it nags all night — which also means // it fires promptly on waking if it was already overdue, matching how a puppy // actually behaves. // reminderKinds are the rules a user can enable, with the interval each starts // at. Order is the order they appear in Settings. var reminderKinds = []struct { Kind string Default int // minutes }{ {"sleep", 45}, {"pee", 60}, {"poo", 180}, {"eat", 240}, } func defaultInterval(kind string) (int, bool) { for _, k := range reminderKinds { if k.Kind == kind { return k.Default, true } } return 0, false } // Reminder is one rule as the client sees it. LastFired is server-owned state // and deliberately absent: a client PUT must never be able to reset it, or a // device with a stale copy could make a rule re-fire immediately. type Reminder struct { Kind string `json:"kind"` Enabled bool `json:"enabled"` IntervalMin int `json:"intervalMin"` } type ReminderStore struct { db *sql.DB } func newReminderStore(db *sql.DB) *ReminderStore { return &ReminderStore{db: db} } // get returns every rule for a user, filling in defaults for kinds they have // never touched so the client always renders the full set. func (rs *ReminderStore) get(userID string) ([]Reminder, error) { rows, err := rs.db.Query( `SELECT kind, enabled, interval_min FROM reminders WHERE user_id = ?`, userID) if err != nil { return nil, err } defer rows.Close() stored := map[string]Reminder{} for rows.Next() { var r Reminder if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin); err != nil { return nil, err } stored[r.Kind] = r } if err := rows.Err(); err != nil { return nil, err } out := make([]Reminder, 0, len(reminderKinds)) for _, k := range reminderKinds { if r, ok := stored[k.Kind]; ok { out = append(out, r) continue } out = append(out, Reminder{Kind: k.Kind, Enabled: false, IntervalMin: k.Default}) } return out, nil } // put writes the rules a client sent. Unknown kinds are ignored and intervals // are clamped, so a bad client can't install a rule that fires every minute. // Enabling a rule clears last_fired so it can alert immediately if already due, // rather than waiting out an interval from whenever it last ran. func (rs *ReminderStore) put(userID string, in []Reminder) error { tx, err := rs.db.Begin() if err != nil { return err } defer tx.Rollback() for _, r := range in { if _, ok := defaultInterval(r.Kind); !ok { continue } if r.IntervalMin < 5 { r.IntervalMin = 5 } if r.IntervalMin > 24*60 { r.IntervalMin = 24 * 60 } if _, err := tx.Exec(` INSERT INTO reminders (user_id, kind, enabled, interval_min, last_fired, updated) VALUES (?, ?, ?, ?, 0, ?) ON CONFLICT(user_id, kind) DO UPDATE SET enabled = excluded.enabled, interval_min = excluded.interval_min, updated = excluded.updated, last_fired = CASE WHEN reminders.enabled = 0 AND excluded.enabled = 1 THEN 0 ELSE reminders.last_fired END`, userID, r.Kind, r.Enabled, r.IntervalMin, time.Now().UnixMilli()); err != nil { return err } } return tx.Commit() } // ---------- subscriptions ---------- type SubscriptionStore struct { db *sql.DB } func newSubscriptionStore(db *sql.DB) *SubscriptionStore { return &SubscriptionStore{db: db} } // save records a device's push subscription. The endpoint is the primary key: // browsers reuse it across sessions, and re-subscribing (which iOS forces // regularly) must update the existing row rather than accumulate dead ones. func (ss *SubscriptionStore) save(userID string, sub Subscription) error { _, err := ss.db.Exec(` INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, created) VALUES (?, ?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth`, sub.Endpoint, userID, sub.Keys.P256dh, sub.Keys.Auth, time.Now().UnixMilli()) return err } func (ss *SubscriptionStore) delete(endpoint string) error { _, err := ss.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint) return err } func (ss *SubscriptionStore) forUser(userID string) ([]Subscription, error) { rows, err := ss.db.Query( `SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?`, userID) if err != nil { return nil, err } defer rows.Close() var out []Subscription for rows.Next() { var s Subscription if err := rows.Scan(&s.Endpoint, &s.Keys.P256dh, &s.Keys.Auth); err != nil { return nil, err } out = append(out, s) } return out, rows.Err() } // ---------- evaluation ---------- // sleepState mirrors currentSleepState() in app.js: the latest sleep boundary // decides whether the puppy is awake, with an equal-timestamp tie broken by the // later-written event so both sides agree on the same answer. type sleepState struct { state string // "asleep", "awake", or "" when nothing has ever been logged since int64 // ms epoch of that boundary } func (sch *Scheduler) sleepStateFor(userID string) (sleepState, error) { var typ string var at int64 err := sch.db.QueryRow(` SELECT type, at FROM events WHERE user_id = ? AND deleted = 0 AND type IN ('sleep-start', 'sleep-end') ORDER BY at DESC, updated DESC LIMIT 1`, userID).Scan(&typ, &at) if errors.Is(err, sql.ErrNoRows) { return sleepState{}, nil } if err != nil { return sleepState{}, err } if typ == "sleep-start" { return sleepState{state: "asleep", since: at}, nil } return sleepState{state: "awake", since: at}, nil } // lastEventAt is the timestamp of the newest surviving event of a type, or 0 // when there is none. func (sch *Scheduler) lastEventAt(userID, typ string) (int64, error) { var at sql.NullInt64 err := sch.db.QueryRow( `SELECT MAX(at) FROM events WHERE user_id = ? AND type = ? AND deleted = 0`, userID, typ).Scan(&at) if err != nil { return 0, err } return at.Int64, nil } // notification is the payload the service worker receives. Tag is what makes a // repeat fire replace the previous notification instead of stacking a new one. type notification struct { Title string `json:"title"` Body string `json:"body"` Tag string `json:"tag"` URL string `json:"url"` } // due decides whether a rule should fire right now, and with what text. The // returned notification is only meaningful when due is true. func (sch *Scheduler) due(userID string, r Reminder, lastFired, now int64, sleep sleepState) (notification, bool, error) { interval := int64(r.IntervalMin) * 60 * 1000 var since int64 var title, body string if r.Kind == "sleep" { // Only meaningful while awake; asleep means the rule has been satisfied. if sleep.state != "awake" { return notification{}, false, nil } since = sleep.since title = "Time to sleep" body = "Awake for " + humanDuration(now-since) } else { // A sleeping puppy isn't going to pee, eat, or poo — stay quiet until it // wakes, at which point an already-overdue rule fires on the next tick. if sleep.state == "asleep" { return notification{}, false, nil } at, err := sch.lastEventAt(userID, r.Kind) if err != nil { return notification{}, false, err } if at == 0 { // Nothing logged yet, so there is no clock to run. return notification{}, false, nil } since = at title, body = reminderText(r.Kind, now-since) } if now-since < interval { return notification{}, false, nil } // Once overdue, repeat at the rule's own interval rather than every tick. if lastFired != 0 && now-lastFired < interval { return notification{}, false, nil } return notification{ Title: title, Body: body, Tag: "reminder:" + r.Kind, URL: "./", }, true, nil } func reminderText(kind string, elapsed int64) (title, body string) { switch kind { case "pee": return "Time for pee", "No pee logged for " + humanDuration(elapsed) case "poo": return "Time for poo", "No poo logged for " + humanDuration(elapsed) case "eat": return "Time for a meal", "No meal logged for " + humanDuration(elapsed) } return "Reminder", "Nothing logged for " + humanDuration(elapsed) } // humanDuration renders an elapsed span the way the notification body reads it: // "45 min", "1 h 12 min", "2 h". func humanDuration(ms int64) string { if ms < 0 { ms = 0 } mins := ms / 60000 h, m := mins/60, mins%60 switch { case h == 0: return fmt.Sprintf("%d min", m) case m == 0: return fmt.Sprintf("%d h", h) default: return fmt.Sprintf("%d h %d min", h, m) } } // ---------- scheduler ---------- // Scheduler evaluates every enabled rule once a minute and pushes the ones that // have come due. One goroutine for the whole server: the work per tick is a // couple of indexed queries per user with a rule switched on. type Scheduler struct { db *sql.DB subs *SubscriptionStore rules *ReminderStore key *VAPIDKey client *http.Client } // reminderTick is how often every enabled rule is re-evaluated. A minute is well // under the shortest interval a rule can be set to, so a reminder never lands // more than a minute late. const reminderTick = time.Minute func newScheduler(db *sql.DB, subs *SubscriptionStore, rules *ReminderStore, key *VAPIDKey) *Scheduler { return &Scheduler{ db: db, subs: subs, rules: rules, key: key, client: &http.Client{Timeout: 30 * time.Second}, } } func (sch *Scheduler) run(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { if err := sch.tick(time.Now().UnixMilli()); err != nil { log.Printf("reminder tick: %v", err) } } } // tick evaluates all rules once. now is a parameter so tests can drive it. func (sch *Scheduler) tick(now int64) error { // Only users who both switched a rule on and have somewhere to push to. rows, err := sch.db.Query(` SELECT DISTINCT r.user_id FROM reminders r WHERE r.enabled = 1 AND EXISTS (SELECT 1 FROM push_subscriptions s WHERE s.user_id = r.user_id)`) if err != nil { return err } var users []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return err } users = append(users, id) } rows.Close() if err := rows.Err(); err != nil { return err } sort.Strings(users) for _, uid := range users { if err := sch.tickUser(uid, now); err != nil { log.Printf("reminders for %s: %v", uid, err) } } return nil } func (sch *Scheduler) tickUser(userID string, now int64) error { sleep, err := sch.sleepStateFor(userID) if err != nil { return err } rows, err := sch.db.Query( `SELECT kind, enabled, interval_min, last_fired FROM reminders WHERE user_id = ? AND enabled = 1`, userID) if err != nil { return err } type pending struct { rule Reminder note notification } var fire []pending for rows.Next() { var r Reminder var lastFired int64 if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin, &lastFired); err != nil { rows.Close() return err } note, ok, err := sch.due(userID, r, lastFired, now, sleep) if err != nil { rows.Close() return err } if ok { fire = append(fire, pending{rule: r, note: note}) } } rows.Close() if err := rows.Err(); err != nil { return err } if len(fire) == 0 { return nil } subs, err := sch.subs.forUser(userID) if err != nil { return err } for _, p := range fire { // Stamp the fire before sending: a push service that is slow or briefly // erroring must not cause the same reminder to be retried every tick. if _, err := sch.db.Exec( `UPDATE reminders SET last_fired = ? WHERE user_id = ? AND kind = ?`, now, userID, p.rule.Kind); err != nil { return err } sch.broadcast(subs, p.note) } return nil } // broadcast sends one notification to every device the user has registered, // pruning any subscription the push service reports as permanently gone. func (sch *Scheduler) broadcast(subs []Subscription, note notification) { payload, err := json.Marshal(note) if err != nil { log.Printf("marshal notification: %v", err) return } for _, sub := range subs { if err := sch.key.send(sch.client, sub, payload, 3600); err != nil { var pe *pushError if errors.As(err, &pe) && pe.Gone { if delErr := sch.subs.delete(sub.Endpoint); delErr != nil { log.Printf("drop dead subscription: %v", delErr) } continue } log.Printf("push send: %v", err) } } } // ---------- handlers ---------- // handleKey hands the client the VAPID public key it must pass as // applicationServerKey when subscribing. Public by design — it only identifies // this server; the private half never leaves it. func (sch *Scheduler) handleKey(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]string{"key": sch.key.Public}) } // handleSubscribe stores (or refreshes) the calling device's subscription. // Clients re-post on every launch, because iOS quietly drops subscriptions and // a stale one would silently stop receiving. func (sch *Scheduler) handleSubscribe(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var sub Subscription if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&sub); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if err := validSubscription(sub); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := sch.subs.save(userID(r), sub); err != nil { log.Printf("save subscription: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handleUnsubscribe drops one endpoint. Scoped to the caller so one account // can't delete another's device. func (sch *Scheduler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Endpoint string `json:"endpoint"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if _, err := sch.db.Exec( `DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, body.Endpoint, userID(r)); err != nil { log.Printf("delete subscription: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // handleTest pushes one notification to the caller's devices. Push failures are // invisible from the browser side — especially on iOS — so this is the only // practical way to tell "not subscribed" apart from "subscribed but undelivered". func (sch *Scheduler) handleTest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } subs, err := sch.subs.forUser(userID(r)) if err != nil { log.Printf("test push: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } if len(subs) == 0 { http.Error(w, "no subscriptions", http.StatusNotFound) return } sch.broadcast(subs, notification{ Title: "Reminders are on", Body: "This is what a reminder looks like.", Tag: "reminder:test", URL: "./", }) w.WriteHeader(http.StatusNoContent) } // handleReminders reads and writes the caller's rules. func (sch *Scheduler) handleReminders(w http.ResponseWriter, r *http.Request) { uid := userID(r) switch r.Method { case http.MethodGet: case http.MethodPut: var body struct { Reminders []Reminder `json:"reminders"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if err := sch.rules.put(uid, body.Reminders); err != nil { log.Printf("put reminders: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Both verbs answer with the stored set, so a PUT tells the client exactly // what was kept after clamping. list, err := sch.rules.get(uid) if err != nil { log.Printf("get reminders: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{"reminders": list}) } // validSubscription rejects anything we could not push to later, so a bad row // never reaches the scheduler. The key sizes are fixed by RFC 8291: an // uncompressed P-256 point and a 16-byte auth secret. func validSubscription(sub Subscription) error { u, err := url.Parse(sub.Endpoint) if err != nil || u.Scheme != "https" || u.Host == "" { return errors.New("endpoint must be an https URL") } if len(sub.Endpoint) > 2048 { return errors.New("endpoint too long") } p256dh, err := b64.DecodeString(sub.Keys.P256dh) if err != nil || len(p256dh) != 65 || p256dh[0] != 4 { return errors.New("bad p256dh key") } auth, err := b64.DecodeString(sub.Keys.Auth) if err != nil || len(auth) != 16 { return errors.New("bad auth secret") } return nil }