package api import ( "encoding/json" "fmt" "net/http" "net/url" "sort" "strings" "time" "drivervault/apiserver/internal/models" ) // Reminders: what the user wants to be told about, and when. // // A reminder fires on a date, an odometer reading, or both (whichever arrives // first). Two kinds are returned side by side: // // - Stored reminders, which the user creates and completes. // - Auto-derived reminders, synthesised on read from data the user has // already entered — a document's expiry date, the next service due from the // latest service record. These are read-only and carry no row of their own, // so a renewal date never has to be typed twice and can never drift out of // step with the document it came from. Their ids are namespaced ("auto:…") // and every write endpoint rejects them. // autoPrefix marks a reminder id as derived rather than stored. const autoPrefix = "auto:" var reminderTypes = map[string]bool{ "maintenance": true, "document": true, "service": true, "inspection": true, "other": true, } // documentTypeLabels give auto-derived document reminders a title that reads // like something a person would write. var documentTypeLabels = map[string]string{ "insurance": "Insurance", "pollution": "Pollution certificate", "registration": "Registration", "inspection": "Inspection", "roadTax": "Road tax", "warranty": "Warranty", "other": "Document", } // listCarReminders serves GET /api/cars/{id}/reminders. func (s *Server) listCarReminders(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondReminderList(w, r, carID) } // listReminders serves GET /api/reminders?car={id}. func (s *Server) listReminders(w http.ResponseWriter, r *http.Request) { carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondReminderList(w, r, carID) } func (s *Server) respondReminderList(w http.ResponseWriter, r *http.Request, carID string) { car, err := s.carModel(r.Context(), carID) if err != nil { writePBError(w, err) return } stored, err := s.fetchReminders(r, carID, car.CurrentKm) if err != nil { writePBError(w, err) return } // A failure to derive extras must not take down the user's own reminders, // which are the part they actually rely on. out := append(stored, s.autoReminders(r, car)...) now := time.Now() sortReminders(out, now) writeJSON(w, http.StatusOK, out) } // fetchReminders loads a car's stored reminders with their status resolved // against today and the car's odometer. func (s *Server) fetchReminders(r *http.Request, carID string, currentKm int) ([]models.Reminder, error) { res, err := s.pb.List(r.Context(), colReminders, url.Values{ "filter": {fmt.Sprintf("car='%s'", carID)}, "perPage": {"500"}, }) if err != nil { return nil, err } var recs []reminderRecord if err := json.Unmarshal(res.Items, &recs); err != nil { return nil, err } now := time.Now() out := make([]models.Reminder, 0, len(recs)) for _, rec := range recs { m := rec.toModel() m.ComputeReminderDerived(now, currentKm) out = append(out, m) } return out, nil } // autoReminders synthesises the read-only reminders implied by a car's // documents and its service schedule. Errors are swallowed: these are a // convenience layered on top of the stored list, and losing them is better than // failing the request. func (s *Server) autoReminders(r *http.Request, car *models.Car) []models.Reminder { now := time.Now() out := []models.Reminder{} // One per document that has a renewal date. if docs, err := s.fetchDocuments(r, car.ID); err == nil { for _, d := range docs { if d.ExpiryDate == nil { continue } label := documentTypeLabels[d.Type] if label == "" { label = "Document" } rem := models.Reminder{ ID: autoPrefix + "doc:" + d.ID, Car: car.ID, Title: label + " renewal — " + d.Title, Type: "document", DueDate: d.ExpiryDate, Auto: true, SourceRef: d.ID, } if d.Provider != "" { rem.Notes = d.Provider } rem.ComputeReminderDerived(now, car.CurrentKm) out = append(out, rem) } } // One for the next service due, from the most recent service record. if latest, err := s.latestServiceRecord(r, car); err == nil && latest != nil { rem := models.Reminder{ ID: autoPrefix + "service:" + latest.ID, Car: car.ID, Title: "Service due", Type: "service", DueDate: latest.NextServiceDate, Auto: true, SourceRef: latest.ID, Notes: fmt.Sprintf("Based on the service on %s", latest.Date.Format("2006-01-02")), } if latest.NextServiceKm != nil { rem.DueKm = *latest.NextServiceKm } if rem.DueDate != nil || rem.DueKm > 0 { rem.ComputeReminderDerived(now, car.CurrentKm) out = append(out, rem) } } return out } // latestServiceRecord returns the car's most recent service record with its // next-due fields computed, or nil when the car has no service history. func (s *Server) latestServiceRecord(r *http.Request, car *models.Car) (*models.ServiceRecord, error) { res, err := s.pb.List(r.Context(), colServices, url.Values{ "filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"1"}, }) if err != nil { return nil, err } var recs []serviceRecord if err := json.Unmarshal(res.Items, &recs); err != nil { return nil, err } if len(recs) == 0 { return nil, nil } m := recs[0].toModel() m.ComputeDerived(car) return &m, nil } // sortReminders orders the list the way it needs to be acted on: everything // outstanding first, soonest deadline at the top, with completed reminders // pushed to the bottom. func sortReminders(rs []models.Reminder, now time.Time) { sort.SliceStable(rs, func(i, j int) bool { a, b := rs[i], rs[j] if a.Done != b.Done { return !a.Done } ad, bd := a.DueDate != nil, b.DueDate != nil if ad != bd { return ad // dated reminders before open-ended ones } if ad && bd && !a.DueDate.Equal(*b.DueDate) { return a.DueDate.Before(*b.DueDate) } return a.Title < b.Title }) } func (s *Server) getReminder(w http.ResponseWriter, r *http.Request) { rec, ok := s.loadStoredReminder(w, r, accessRead) if !ok { return } car, err := s.carModel(r.Context(), rec.Car) if err != nil { writePBError(w, err) return } m := rec.toModel() m.ComputeReminderDerived(time.Now(), car.CurrentKm) writeJSON(w, http.StatusOK, m) } func (s *Server) createReminder(w http.ResponseWriter, r *http.Request) { var in models.Reminder if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.Type == "" { in.Type = "other" } if msg := validateReminder(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec reminderRecord if err := s.pb.Create(r.Context(), colReminders, reminderPayload(in), &rec); err != nil { writePBError(w, err) return } s.respondReminder(w, r, rec) } func (s *Server) updateReminder(w http.ResponseWriter, r *http.Request) { var in models.Reminder if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } existing, ok := s.loadStoredReminder(w, r, accessWrite) if !ok { return } in.Car = existing.Car // the reminder's car is not reassignable via PATCH if in.Type == "" { in.Type = "other" } if msg := validateReminder(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } // Completing via PATCH should still stamp when it happened, matching what // the complete endpoint records. if in.Done && in.DoneAt == nil { now := time.Now() in.DoneAt = &now } if !in.Done { in.DoneAt = nil } var rec reminderRecord if err := s.pb.Update(r.Context(), colReminders, existing.ID, reminderPayload(in), &rec); err != nil { writePBError(w, err) return } s.respondReminder(w, r, rec) } // handleCompleteReminder serves POST /api/reminders/{id}/complete. // // A one-off reminder is simply closed. A recurring one (repeatDays/repeatKm) // instead rolls its triggers forward and stays open — the next oil change is // due a year after this one was done, not a year after it was first scheduled, // so the roll is measured from completion. func (s *Server) handleCompleteReminder(w http.ResponseWriter, r *http.Request) { existing, ok := s.loadStoredReminder(w, r, accessWrite) if !ok { return } m := existing.toModel() now := time.Now() payload := map[string]any{} if m.RepeatDays > 0 || m.RepeatKm > 0 { if m.RepeatDays > 0 { next := now.AddDate(0, 0, m.RepeatDays) payload["due_date"] = formatPBDate(next) } if m.RepeatKm > 0 { car, err := s.carModel(r.Context(), m.Car) if err != nil { writePBError(w, err) return } // Roll from where the car actually is: the work was done now, so the // next one is due RepeatKm from this reading, whether it was done // early or late. Rolling from the old target instead would let an // early completion drift the schedule forward for good. // // A reading of 0 rolls from 0 like any other: a car collected new is // genuinely there, and CurrentKm + RepeatKm is ahead of the car by // construction, so this cannot land a target in the past. payload["due_km"] = car.CurrentKm + m.RepeatKm } payload["done"] = false payload["done_at"] = "" } else { payload["done"] = true payload["done_at"] = formatPBDate(now) } var rec reminderRecord if err := s.pb.Update(r.Context(), colReminders, existing.ID, payload, &rec); err != nil { writePBError(w, err) return } s.respondReminder(w, r, rec) } func (s *Server) deleteReminder(w http.ResponseWriter, r *http.Request) { existing, ok := s.loadStoredReminder(w, r, accessWrite) if !ok { return } if err := s.pb.Delete(r.Context(), colReminders, existing.ID); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // loadStoredReminder fetches the {id} reminder and checks car access at `need`. // Auto-derived ids are rejected up front: they have no row behind them, so any // write against one is a client bug rather than a 404 from PocketBase. func (s *Server) loadStoredReminder(w http.ResponseWriter, r *http.Request, need string) (reminderRecord, bool) { id := r.PathValue("id") if strings.HasPrefix(id, autoPrefix) { writeError(w, http.StatusBadRequest, "this reminder is derived from a document or service record — edit that instead") return reminderRecord{}, false } var rec reminderRecord if err := s.pb.GetOne(r.Context(), colReminders, id, &rec); err != nil { writePBError(w, err) return reminderRecord{}, false } if !s.requireCarAccess(w, r, rec.Car, need) { return reminderRecord{}, false } return rec, true } // respondReminder writes a stored reminder with its derived status resolved. func (s *Server) respondReminder(w http.ResponseWriter, r *http.Request, rec reminderRecord) { m := rec.toModel() currentKm := 0 if car, err := s.carModel(r.Context(), rec.Car); err == nil { currentKm = car.CurrentKm } m.ComputeReminderDerived(time.Now(), currentKm) writeJSON(w, http.StatusOK, m) } func validateReminder(rm models.Reminder) string { switch { case rm.Car == "": return "car is required" case strings.TrimSpace(rm.Title) == "": return "title is required" case !reminderTypes[rm.Type]: return "type must be one of: maintenance, document, service, inspection, other" case rm.DueKm < 0 || rm.RepeatDays < 0 || rm.RepeatKm < 0: return "due km and repeat values cannot be negative" } // A reminder with neither trigger would never come due, which is not what // anyone means by "remind me". if (rm.DueDate == nil || rm.DueDate.IsZero()) && rm.DueKm == 0 { return "set a due date, a due odometer reading, or both" } return "" }