package api import ( "encoding/json" "errors" "net/http" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/store" ) // The settings history: every revision of one person's synced settings, what each one // changed, which televisions have taken it, and how to put an old one back. // // It exists because the revision is a delivery mechanism with no receipt. /v1/status hands // every open television a number and the set fetches the document when it differs — which // works, and tells the operator nothing. "I changed their card size an hour ago and the // bedroom TV still looks wrong" has three possible answers (it never fetched, it fetched // and something else has since overwritten it, or it is fine and the complaint is about // something else) and no way to tell them apart. Acks are what separate them. // // Nothing here rewinds a revision. A restore is a *new* revision carrying an old document, // for the same reason the counter only ever goes up: a television compares numbers, so one // that went backwards would leave every set in the house believing it was already up to // date and holding settings the operator had just replaced. // adminHistoryLimit is how many revisions the page asks for. The store caps it too; this // is the page saying what it can usefully draw. const adminHistoryLimit = 60 type adminPreferenceRevision struct { Revision int64 `json:"revision"` Source string `json:"source"` // Author is who made the change, in the console's words: a television's name, or the // admin console itself. Recorded at write time, so a set signed out since still has // a name here. Author string `json:"author"` DeviceID string `json:"deviceId,omitempty"` ClientVersion string `json:"clientVersion,omitempty"` RestoredFrom int64 `json:"restoredFrom,omitempty"` CreatedAt time.Time `json:"createdAt"` Current bool `json:"current"` Changes []preferenceChange `json:"changes"` // Initial marks the first revision this history holds. Its "changes" are measured // against the defaults, and calling that a change would be a claim about a decision // nobody made — so the page labels it rather than listing it. Initial bool `json:"initial"` Preferences map[string]any `json:"preferences"` Acks []store.PreferenceAck `json:"acks"` } // adminPreferenceDevice is one television's position: which revision it holds, and how far // that is behind the current one. type adminPreferenceDevice struct { DeviceID string `json:"deviceId"` Name string `json:"name"` ClientVersion string `json:"clientVersion,omitempty"` LastSeen time.Time `json:"lastSeen"` SignedOut bool `json:"signedOut"` Revision int64 `json:"revision"` AckedAt any `json:"ackedAt,omitempty"` // Behind is how many revisions this set has yet to take. Zero is up to date; the // field is what the page sorts and colours by. Behind int64 `json:"behind"` // Never is a set that has never fetched the document at all — a fresh sign-in, or a // build that predates synced settings. Distinct from being behind, because there is // nothing to roll back to for it. Never bool `json:"never"` } // handleAdminPreferenceHistory answers the whole page in one request: the revisions, what // each changed, and where every television has got to. func (s *Server) handleAdminPreferenceHistory(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } history, err := s.store.UserPreferenceHistory(r.Context(), userID, adminHistoryLimit) if err != nil { s.loggerFor(r.Context()).Error("preference history read failed", "user", userID, "error", err) writeError(w, http.StatusInternalServerError, "could not read the settings history") return } current, err := s.store.UserPreferences(r.Context(), userID) if err != nil { s.loggerFor(r.Context()).Error("preference read failed", "user", userID, "error", err) writeError(w, http.StatusInternalServerError, "could not read these settings") return } // One account read for both the person's name and their televisions. A failure costs // the page its device names, never the history itself — which is the half that cannot // be reconstructed from anywhere else. account, err := s.membyAccount(r, userID) if err != nil { s.loggerFor(r.Context()).Warn("account unavailable for settings history", "error", err) } writeJSON(w, http.StatusOK, map[string]any{ "userId": userID, "username": account.Username, "currentRevision": current.Revision, "currentSource": current.Source, "updatedAt": optionalTime(current.UpdatedAt), "saved": current.Revision > 0, "revisions": adminRevisions(history, current.Revision), "devices": s.adminPreferenceDevices(r, userID, account.Devices, current.Revision), "catalogue": preferenceCatalogue, "schemaVersion": preferenceSchemaVersion, }) } // adminRevisions turns stored revisions into what the table draws. It is a pure function // of the history so it can be tested without a database, which is the only way to pin the // two rules that are easy to get wrong: the diff is against the revision *below* this one, // and the oldest entry held has nothing below it to be a diff of. func adminRevisions(history []store.PreferenceRevision, currentRevision int64) []adminPreferenceRevision { result := make([]adminPreferenceRevision, 0, len(history)) for index, entry := range history { document := decodePreferences(entry.Preferences) // history is newest first, so the predecessor is the next element along. initial := index == len(history)-1 changes := []preferenceChange{} if !initial { changes = preferenceChanges(decodePreferences(history[index+1].Preferences), document) } acks := entry.Acks if acks == nil { acks = []store.PreferenceAck{} } result = append(result, adminPreferenceRevision{ Revision: entry.Revision, Source: entry.Source, Author: revisionAuthor(entry), DeviceID: entry.DeviceID, ClientVersion: entry.ClientVersion, RestoredFrom: entry.RestoredFrom, CreatedAt: entry.CreatedAt, Current: entry.Revision == currentRevision, Changes: changes, Initial: initial, Preferences: document, Acks: acks, }) } return result } // revisionAuthor names who made a change in the console's own vocabulary. A device row // with no name is an older write, from before the history recorded one — "a television" is // honest where inventing a name would not be. func revisionAuthor(entry store.PreferenceRevision) string { switch { case entry.Source == "admin": return "Admin console" case entry.DeviceName != "": return entry.DeviceName case entry.Source == "device": return "A television" default: return entry.Source } } // adminPreferenceDevices lists every television signed in to this account beside the // revision it holds. Signed-in sets come first because they are the ones an operator can // still expect to catch up; a set that has acked but is no longer signed in is kept so its // last known position is not silently dropped. func (s *Server) adminPreferenceDevices( r *http.Request, userID string, known []store.MembyDevice, currentRevision int64, ) []adminPreferenceDevice { states, err := s.store.PreferenceDeviceStates(r.Context(), userID) if err != nil { s.loggerFor(r.Context()).Warn("preference device states unavailable", "error", err) states = map[string]store.PreferenceDeviceState{} } devices := []adminPreferenceDevice{} seen := map[string]bool{} for _, device := range known { state, acked := states[device.ID] seen[device.ID] = true devices = append(devices, adminPreferenceDevice{ DeviceID: device.ID, Name: device.Name, ClientVersion: device.Version, LastSeen: device.LastSeen, Revision: state.Revision, AckedAt: optionalTime(state.AckedAt), Never: !acked, Behind: behindBy(currentRevision, state.Revision), }) } for deviceID, state := range states { if seen[deviceID] { continue } devices = append(devices, adminPreferenceDevice{ DeviceID: deviceID, Name: "Signed-out television", SignedOut: true, Revision: state.Revision, AckedAt: optionalTime(state.AckedAt), Behind: behindBy(currentRevision, state.Revision), }) } sort.SliceStable(devices, func(i, j int) bool { if devices[i].SignedOut != devices[j].SignedOut { return devices[j].SignedOut } if devices[i].Behind != devices[j].Behind { return devices[i].Behind > devices[j].Behind } return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name) }) return devices } // behindBy never goes negative. A television holding a revision above the current one is // impossible in ordinary operation, but the counter is the whole delivery mechanism and a // negative "behind" on the page would read as nonsense rather than as the anomaly it is. func behindBy(current, held int64) int64 { if held >= current { return 0 } return current - held } func optionalTime(value time.Time) any { if value.IsZero() { return nil } return value } // membyAccount is one person out of the same list the console's other pages read. Somebody // with no signed-in television is not an error: their settings history outlives their // devices, and is one of the more useful times to be able to read it. func (s *Server) membyAccount(r *http.Request, userID string) (store.MembyAccount, error) { accounts, err := s.store.MembyAccounts(r.Context()) if err != nil { return store.MembyAccount{}, err } for _, account := range accounts { if account.ID == userID { return account, nil } } return store.MembyAccount{}, nil } // handleAdminRestorePreferences puts an earlier document back. // // It is a forward write, never a rewind: the restored document goes out as the next // revision, attributed to the console and recording what it was taken from. That is what // makes it visible to televisions at all — they compare revision numbers — and it is also // what makes a restore itself undoable, since the revision it replaced is still sitting in // the history one row down. // // The document is re-normalised on the way out. A revision written before a setting // existed has nothing to say about it, and the catalogue's default is the right answer // there; a revision written before a setting's options changed may hold one that is no // longer legal, and restoring it verbatim would put a value on a television that the // server itself would reject. func (s *Server) handleAdminRestorePreferences(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) revision, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("revision")), 10, 64) if userID == "" || err != nil || revision <= 0 { writeError(w, http.StatusBadRequest, "user and revision are required") return } document, err := s.store.PreferenceRevisionDocument(r.Context(), userID, revision) if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "that revision is no longer held") return } if err != nil { s.loggerFor(r.Context()).Error("preference revision read failed", "user", userID, "revision", revision, "error", err) writeError(w, http.StatusInternalServerError, "could not read that revision") return } values := map[string]any{} _ = json.Unmarshal(document, &values) raw, err := json.Marshal(normalizePreferences(values)) if err != nil { writeError(w, http.StatusInternalServerError, "could not rebuild that revision") return } // ForceRevision for the same reason an ordinary push takes it: every one of this // person's televisions is also a writer of this row, and an operator deliberately // restoring a version must not lose a race to whichever set syncs next. stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{ Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin", RestoredFrom: revision, }) if err != nil { s.loggerFor(r.Context()).Error("preference restore failed", "user", userID, "revision", revision, "error", err) writeError(w, http.StatusInternalServerError, "could not restore that revision") return } s.loggerFor(r.Context()).Info("settings restored", "user", userID, "revision", stored.Revision, "restoredFrom", revision, "source", "admin") writeJSON(w, http.StatusOK, preferencesPayload(stored)) }