Add fuel, maintenance, document and reminder tracking
Four features layered onto cars, each following the existing parts/services pattern: a Go handler gated on requireCarAccess, snake_case PocketBase mappers, a Vue form modal, and a tab on CarDetail (now driven by an array rather than repeated markup). Fuel: refills logged with odometer, litres and cost. Consumption is derived on read from the whole history rather than stored, so correcting an old fill re-derives every window it touches with no rows to migrate. Efficiency uses the full-tank method — two consecutive full tanks are the same known level, so the fuel burned between them is exactly what was poured in. Partial fills roll into the window that closes them; a missed-fill flag leaves that window uncomputed rather than reporting an implausibly good figure. Averages in the stats rollup are distance-weighted, so a long motorway run counts for more than a trip across town — which is what actually happened to the fuel. Maintenance: workshop visits and repairs, deliberately separate from service_records. That collection is the routine interval schedule and drives next-service-due; this one is unplanned garage work with a workshop, an invoice and a labour bill, and no bearing on the interval. Documents: insurance, pollution certificates and registration papers. The renewal date is the point of the record, so expiry is assessed live on every read instead of stored and left to go stale. Scans are proxied through the API — PocketBase's collections have no public read rule, so an attachment is never a public URL and car access is re-checked per fetch. Reminders: fire on a date, an odometer reading, or both (whichever comes first). Stored reminders sit alongside read-only ones derived from document expiry and next-service-due, so a renewal date is never typed twice and can never drift from the document it came from. Derived ids are namespaced "auto:" and every write endpoint rejects them. A refill or a completed visit also writes the car's odometer forward, since it is the freshest reading there is — never backwards, so backfilling old history can't rewind the car. Adds fuel_entries, maintenance_entries, car_documents and reminders to the idempotent schema script, plus a file-field builder for attachments. Verified end-to-end against a live PocketBase with a throwaway account: 39 checks covering the efficiency maths, expiry states, the derived reminders, the upload/download round-trip, and that a stranger can reach none of it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae6ed4ac1e
commit
e64c89a564
@@ -0,0 +1,327 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// Document tracking: insurance policies, pollution/emissions certificates,
|
||||
// registration papers and the like, each with a renewal date.
|
||||
//
|
||||
// The expiry date is the reason the feature exists — a lapsed policy is a car
|
||||
// that cannot legally be driven — so it is assessed live on every read
|
||||
// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
|
||||
// Documents also feed the auto-derived reminders in reminders.go.
|
||||
//
|
||||
// The scan/PDF lives in PocketBase's file storage and is reached only through
|
||||
// this server's superuser service account, so an attachment is never a public
|
||||
// URL: clients fetch it from GET /api/car-documents/{id}/file, which re-checks
|
||||
// car access on every request.
|
||||
|
||||
// maxDocumentUpload caps an attachment at 10 MB — comfortably above a scanned
|
||||
// certificate, well below anything that would tie up the server.
|
||||
const maxDocumentUpload = 10 << 20
|
||||
|
||||
var documentTypes = map[string]bool{
|
||||
"insurance": true,
|
||||
"pollution": true,
|
||||
"registration": true,
|
||||
"inspection": true,
|
||||
"roadTax": true,
|
||||
"warranty": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// listCarDocuments serves GET /api/cars/{id}/documents.
|
||||
func (s *Server) listCarDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondDocumentList(w, r, carID)
|
||||
}
|
||||
|
||||
// listDocuments serves GET /api/car-documents?car={id}.
|
||||
func (s *Server) listDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondDocumentList(w, r, carID)
|
||||
}
|
||||
|
||||
func (s *Server) respondDocumentList(w http.ResponseWriter, r *http.Request, carID string) {
|
||||
docs, err := s.fetchDocuments(r, carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
// fetchDocuments loads a car's documents with the soonest renewal first — the
|
||||
// order in which they need attention.
|
||||
func (s *Server) fetchDocuments(r *http.Request, carID string) ([]models.CarDocument, error) {
|
||||
res, err := s.pb.List(r.Context(), colDocuments, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", carID)},
|
||||
"perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []documentRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
out := make([]models.CarDocument, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(now)
|
||||
out = append(out, d)
|
||||
}
|
||||
|
||||
// Sorted here rather than by PocketBase: it orders a blank expiry_date ahead
|
||||
// of every real date, which would file the documents that never expire above
|
||||
// the ones that have already lapsed — the exact inverse of what this list is
|
||||
// for. Everything with a renewal date comes first, soonest at the top.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
ae, be := a.ExpiryDate != nil, b.ExpiryDate != nil
|
||||
if ae != be {
|
||||
return ae
|
||||
}
|
||||
if ae && be && !a.ExpiryDate.Equal(*b.ExpiryDate) {
|
||||
return a.ExpiryDate.Before(*b.ExpiryDate)
|
||||
}
|
||||
return a.Title < b.Title
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) {
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (s *Server) createDocument(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.CarDocument
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Type == "" {
|
||||
in.Type = "other"
|
||||
}
|
||||
if msg := validateDocument(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec documentRecord
|
||||
if err := s.pb.Create(r.Context(), colDocuments, documentPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
func (s *Server) updateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.CarDocument
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
in.Car = existing.Car // the document's car is not reassignable via PATCH
|
||||
if in.Type == "" {
|
||||
in.Type = "other"
|
||||
}
|
||||
if msg := validateDocument(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
var rec documentRecord
|
||||
if err := s.pb.Update(r.Context(), colDocuments, r.PathValue("id"), documentPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (s *Server) deleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colDocuments, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUploadDocumentFile serves POST /api/car-documents/{id}/file — the
|
||||
// scan/PDF for an existing document. Replaces whatever was attached before.
|
||||
func (s *Server) handleUploadDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxDocumentUpload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxDocumentUpload+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > maxDocumentUpload {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
if msg := validateDocumentFile(header.Filename); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), colDocuments, existing.ID, nil, "file", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, existing.ID, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// handleGetDocumentFile serves GET /api/car-documents/{id}/file. The bytes are
|
||||
// proxied through this server because PocketBase's collections have no public
|
||||
// access rules — only the service account can read them.
|
||||
func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
if rec.File == "" {
|
||||
writeError(w, http.StatusNotFound, "no file attached")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), colDocuments, rec.ID, rec.File)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// The stored name is PocketBase's slugified one; quotes are stripped so a
|
||||
// crafted filename can't break out of the header.
|
||||
name := strings.ReplaceAll(rec.File, `"`, "")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentFile serves DELETE /api/car-documents/{id}/file, detaching
|
||||
// the attachment but keeping the document's metadata.
|
||||
func (s *Server) handleDeleteDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), colDocuments, existing.ID, map[string]any{"file": nil}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateDocument(d models.CarDocument) string {
|
||||
switch {
|
||||
case d.Car == "":
|
||||
return "car is required"
|
||||
case strings.TrimSpace(d.Title) == "":
|
||||
return "title is required"
|
||||
case !documentTypes[d.Type]:
|
||||
return "type must be one of: insurance, pollution, registration, inspection, roadTax, warranty, other"
|
||||
case d.Cost < 0:
|
||||
return "cost cannot be negative"
|
||||
}
|
||||
if d.IssueDate != nil && d.ExpiryDate != nil && d.ExpiryDate.Before(*d.IssueDate) {
|
||||
return "expiry date cannot be before the issue date"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// documentFileTypes are the extensions an attachment may carry. The list is
|
||||
// restrictive on purpose: these documents are scans, and anything executable has
|
||||
// no business being stored and handed back out.
|
||||
var documentFileTypes = map[string]bool{
|
||||
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
|
||||
}
|
||||
|
||||
func validateDocumentFile(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if !documentFileTypes[ext] {
|
||||
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// Fuel tracking: a log of refills per car, plus the efficiency derived from it.
|
||||
//
|
||||
// Nothing about consumption is stored — it is recomputed from the whole history
|
||||
// on every read (models.ComputeFuelDerived). Editing a fill three months back
|
||||
// therefore corrects every window it touches, with no rows to migrate.
|
||||
|
||||
// fetchFuelEntries loads a car's refills oldest-first and fills in the derived
|
||||
// efficiency fields. Ordering is by odometer rather than date because the
|
||||
// full-tank windows are spans of distance, and a fill logged with the wrong date
|
||||
// would otherwise scramble the chain.
|
||||
func (s *Server) fetchFuelEntries(r *http.Request, carID string) ([]models.FuelEntry, error) {
|
||||
res, err := s.pb.List(r.Context(), colFuel, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", carID)},
|
||||
"sort": {"km"},
|
||||
"perPage": {"1000"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []fuelRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.FuelEntry, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toModel())
|
||||
}
|
||||
// PocketBase sorts numerically here, but re-sorting locally keeps the
|
||||
// invariant ComputeFuelDerived depends on explicit and cheap.
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Km < out[j].Km })
|
||||
models.ComputeFuelDerived(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listCarFuelEntries serves GET /api/cars/{id}/fuel-entries, newest first.
|
||||
func (s *Server) listCarFuelEntries(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
entries, err := s.fetchFuelEntries(r, carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reverseFuel(entries))
|
||||
}
|
||||
|
||||
// listFuelEntries serves GET /api/fuel-entries?car={id}.
|
||||
func (s *Server) listFuelEntries(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
entries, err := s.fetchFuelEntries(r, carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reverseFuel(entries))
|
||||
}
|
||||
|
||||
// listCarFuelStats serves GET /api/cars/{id}/fuel-stats.
|
||||
func (s *Server) listCarFuelStats(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
entries, err := s.fetchFuelEntries(r, carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, models.ComputeFuelStats(entries))
|
||||
}
|
||||
|
||||
// reverseFuel flips the oldest-first working order into the newest-first order
|
||||
// clients display.
|
||||
func reverseFuel(in []models.FuelEntry) []models.FuelEntry {
|
||||
out := make([]models.FuelEntry, len(in))
|
||||
for i, e := range in {
|
||||
out[len(in)-1-i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withFuelDerived recomputes the whole history and returns the one entry the
|
||||
// caller just wrote, so a create/update response carries the same derived
|
||||
// figures the list would show.
|
||||
func (s *Server) withFuelDerived(r *http.Request, carID, id string) (models.FuelEntry, error) {
|
||||
entries, err := s.fetchFuelEntries(r, carID)
|
||||
if err != nil {
|
||||
return models.FuelEntry{}, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ID == id {
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
return models.FuelEntry{}, fmt.Errorf("fuel entry %s not found after write", id)
|
||||
}
|
||||
|
||||
func (s *Server) getFuelEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var rec fuelRecord
|
||||
if err := s.pb.GetOne(r.Context(), colFuel, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
func (s *Server) createFuelEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.FuelEntry
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateFuelEntry(in); err != "" {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec fuelRecord
|
||||
if err := s.pb.Create(r.Context(), colFuel, fuelPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
// A refill is also the freshest odometer reading there is; keeping the car in
|
||||
// step means the km-based service and reminder status stay honest without the
|
||||
// user retyping the number on the car itself.
|
||||
s.advanceOdometer(r, in.Car, in.Km)
|
||||
|
||||
entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, entry)
|
||||
}
|
||||
|
||||
func (s *Server) updateFuelEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.FuelEntry
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var existing fuelRecord
|
||||
if err := s.pb.GetOne(r.Context(), colFuel, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
// The car is fixed by the record being edited; a body claiming another car
|
||||
// must not move the entry across the access boundary just checked.
|
||||
in.Car = existing.Car
|
||||
if err := validateFuelEntry(in); err != "" {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var rec fuelRecord
|
||||
if err := s.pb.Update(r.Context(), colFuel, r.PathValue("id"), fuelPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
s.advanceOdometer(r, rec.Car, in.Km)
|
||||
|
||||
entry, err := s.withFuelDerived(r, rec.Car, rec.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
func (s *Server) deleteFuelEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var existing fuelRecord
|
||||
if err := s.pb.GetOne(r.Context(), colFuel, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colFuel, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// validateFuelEntry returns a human-readable reason the entry is unusable, or ""
|
||||
// when it is fine.
|
||||
func validateFuelEntry(f models.FuelEntry) string {
|
||||
switch {
|
||||
case f.Car == "":
|
||||
return "car is required"
|
||||
case f.Date.IsZero():
|
||||
return "date is required"
|
||||
case f.Km <= 0:
|
||||
return "odometer (km) is required"
|
||||
case f.Liters <= 0:
|
||||
return "liters must be greater than zero"
|
||||
case f.Cost < 0:
|
||||
return "cost cannot be negative"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// advanceOdometer moves the car's current_km forward to km. It only ever
|
||||
// increases the reading and never fails the caller's request: a stale odometer
|
||||
// is a cosmetic problem, whereas rejecting a valid refill over it is not. A
|
||||
// lower km means the user is backfilling older history, which must not rewind
|
||||
// the car.
|
||||
func (s *Server) advanceOdometer(r *http.Request, carID string, km int) {
|
||||
if km <= 0 {
|
||||
return
|
||||
}
|
||||
var car carRecord
|
||||
if err := s.pb.GetOne(r.Context(), colCars, carID, &car); err != nil {
|
||||
return
|
||||
}
|
||||
if km <= car.CurrentKm {
|
||||
return
|
||||
}
|
||||
_ = s.pb.Update(r.Context(), colCars, carID, map[string]any{"current_km": km}, nil)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// Maintenance log: workshop visits and repairs.
|
||||
//
|
||||
// This is deliberately NOT the service history. A ServiceRecord is the routine,
|
||||
// interval-driven schedule the spreadsheet was built around (oil at 15000 km,
|
||||
// filters once a year) and it drives the next-service-due calculation. A
|
||||
// MaintenanceEntry is unplanned or one-off work done at a garage — a failed
|
||||
// alternator, a clutch, bodywork after a scrape — which has a workshop, an
|
||||
// invoice, and a labour bill, and no bearing on the service interval.
|
||||
|
||||
// maintenanceTypes are the kinds of visit the log accepts. Anything else is
|
||||
// rejected rather than silently stored, so the UI's filters stay meaningful.
|
||||
var maintenanceTypes = map[string]bool{
|
||||
"repair": true,
|
||||
"inspection": true,
|
||||
"bodywork": true,
|
||||
"tyres": true,
|
||||
"diagnostics": true,
|
||||
"recall": true,
|
||||
"warranty": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
var maintenanceStatuses = map[string]bool{
|
||||
"scheduled": true,
|
||||
"in_progress": true,
|
||||
"completed": true,
|
||||
}
|
||||
|
||||
// listCarMaintenance serves GET /api/cars/{id}/maintenance.
|
||||
func (s *Server) listCarMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondMaintenanceList(w, r, carID)
|
||||
}
|
||||
|
||||
// listMaintenance serves GET /api/maintenance?car={id}.
|
||||
func (s *Server) listMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
s.respondMaintenanceList(w, r, carID)
|
||||
}
|
||||
|
||||
func (s *Server) respondMaintenanceList(w http.ResponseWriter, r *http.Request, carID string) {
|
||||
entries, err := s.fetchMaintenance(r, carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// fetchMaintenance loads a car's maintenance entries newest-first with derived
|
||||
// cost and warranty fields filled in.
|
||||
func (s *Server) fetchMaintenance(r *http.Request, carID string) ([]models.MaintenanceEntry, error) {
|
||||
res, err := s.pb.List(r.Context(), colMaintenance, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", carID)},
|
||||
"sort": {"-date"},
|
||||
"perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []maintenanceRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
out := make([]models.MaintenanceEntry, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
m := rec.toModel()
|
||||
m.ComputeMaintenanceDerived(now)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) getMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
var rec maintenanceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.ComputeMaintenanceDerived(time.Now())
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) createMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.MaintenanceEntry
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
applyMaintenanceDefaults(&in)
|
||||
if msg := validateMaintenance(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec maintenanceRecord
|
||||
if err := s.pb.Create(r.Context(), colMaintenance, maintenancePayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
// Only completed work proves the car actually reached that odometer; a
|
||||
// scheduled visit carries an estimate of where it will be.
|
||||
if in.Status == "completed" {
|
||||
s.advanceOdometer(r, in.Car, in.Km)
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.ComputeMaintenanceDerived(time.Now())
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (s *Server) updateMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.MaintenanceEntry
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var existing maintenanceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
in.Car = existing.Car // the record's car is not reassignable via PATCH
|
||||
applyMaintenanceDefaults(&in)
|
||||
if msg := validateMaintenance(in); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
var rec maintenanceRecord
|
||||
if err := s.pb.Update(r.Context(), colMaintenance, r.PathValue("id"), maintenancePayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if in.Status == "completed" {
|
||||
s.advanceOdometer(r, rec.Car, in.Km)
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.ComputeMaintenanceDerived(time.Now())
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) deleteMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
var existing maintenanceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colMaintenance, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func applyMaintenanceDefaults(m *models.MaintenanceEntry) {
|
||||
if m.Type == "" {
|
||||
m.Type = "repair"
|
||||
}
|
||||
if m.Status == "" {
|
||||
m.Status = "completed"
|
||||
}
|
||||
}
|
||||
|
||||
func validateMaintenance(m models.MaintenanceEntry) string {
|
||||
switch {
|
||||
case m.Car == "":
|
||||
return "car is required"
|
||||
case m.Date.IsZero():
|
||||
return "date is required"
|
||||
case m.Description == "":
|
||||
return "description is required"
|
||||
case !maintenanceTypes[m.Type]:
|
||||
return "type must be one of: repair, inspection, bodywork, tyres, diagnostics, recall, warranty, other"
|
||||
case !maintenanceStatuses[m.Status]:
|
||||
return "status must be one of: scheduled, in_progress, completed"
|
||||
case m.LaborCost < 0 || m.PartsCost < 0:
|
||||
return "costs cannot be negative"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -190,3 +190,238 @@ func partPayload(p models.Part) map[string]any {
|
||||
"category": p.Category,
|
||||
}
|
||||
}
|
||||
|
||||
// parsePBDatePtr is parsePBDate for optional dates: a blank or unparseable
|
||||
// value yields nil rather than the zero time, so "no expiry" stays
|
||||
// distinguishable from "expired in year zero".
|
||||
func parsePBDatePtr(s string) *time.Time {
|
||||
t := parsePBDate(s)
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// formatPBDatePtr is formatPBDate for optional dates; nil writes an empty value,
|
||||
// which is how PocketBase stores "unset".
|
||||
func formatPBDatePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return formatPBDate(*t)
|
||||
}
|
||||
|
||||
// --- fuel entries ---
|
||||
|
||||
type fuelRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Date string `json:"date"`
|
||||
Km int `json:"km"`
|
||||
Liters float64 `json:"liters"`
|
||||
Cost float64 `json:"cost"`
|
||||
FullTank bool `json:"full_tank"`
|
||||
MissedFill bool `json:"missed_fill"`
|
||||
Station string `json:"station"`
|
||||
Notes string `json:"notes"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec fuelRecord) toModel() models.FuelEntry {
|
||||
return models.FuelEntry{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Date: parsePBDate(rec.Date),
|
||||
Km: rec.Km,
|
||||
Liters: rec.Liters,
|
||||
Cost: rec.Cost,
|
||||
FullTank: rec.FullTank,
|
||||
MissedFill: rec.MissedFill,
|
||||
Station: rec.Station,
|
||||
Notes: rec.Notes,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func fuelPayload(f models.FuelEntry) map[string]any {
|
||||
return map[string]any{
|
||||
"car": f.Car,
|
||||
"date": formatPBDate(f.Date),
|
||||
"km": f.Km,
|
||||
"liters": f.Liters,
|
||||
"cost": f.Cost,
|
||||
"full_tank": f.FullTank,
|
||||
"missed_fill": f.MissedFill,
|
||||
"station": f.Station,
|
||||
"notes": f.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// --- maintenance entries ---
|
||||
|
||||
type maintenanceRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Date string `json:"date"`
|
||||
Km int `json:"km"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Workshop string `json:"workshop"`
|
||||
Location string `json:"location"`
|
||||
Description string `json:"description"`
|
||||
PartsUsed string `json:"parts_used"`
|
||||
LaborCost float64 `json:"labor_cost"`
|
||||
PartsCost float64 `json:"parts_cost"`
|
||||
InvoiceNumber string `json:"invoice_number"`
|
||||
WarrantyUntil string `json:"warranty_until"`
|
||||
Notes string `json:"notes"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec maintenanceRecord) toModel() models.MaintenanceEntry {
|
||||
return models.MaintenanceEntry{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Date: parsePBDate(rec.Date),
|
||||
Km: rec.Km,
|
||||
Type: rec.Type,
|
||||
Status: rec.Status,
|
||||
Workshop: rec.Workshop,
|
||||
Location: rec.Location,
|
||||
Description: rec.Description,
|
||||
PartsUsed: rec.PartsUsed,
|
||||
LaborCost: rec.LaborCost,
|
||||
PartsCost: rec.PartsCost,
|
||||
InvoiceNumber: rec.InvoiceNumber,
|
||||
WarrantyUntil: parsePBDatePtr(rec.WarrantyUntil),
|
||||
Notes: rec.Notes,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func maintenancePayload(m models.MaintenanceEntry) map[string]any {
|
||||
return map[string]any{
|
||||
"car": m.Car,
|
||||
"date": formatPBDate(m.Date),
|
||||
"km": m.Km,
|
||||
"type": m.Type,
|
||||
"status": m.Status,
|
||||
"workshop": m.Workshop,
|
||||
"location": m.Location,
|
||||
"description": m.Description,
|
||||
"parts_used": m.PartsUsed,
|
||||
"labor_cost": m.LaborCost,
|
||||
"parts_cost": m.PartsCost,
|
||||
"invoice_number": m.InvoiceNumber,
|
||||
"warranty_until": formatPBDatePtr(m.WarrantyUntil),
|
||||
"notes": m.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// --- car documents ---
|
||||
|
||||
type documentRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Provider string `json:"provider"`
|
||||
Reference string `json:"reference"`
|
||||
IssueDate string `json:"issue_date"`
|
||||
ExpiryDate string `json:"expiry_date"`
|
||||
Cost float64 `json:"cost"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec documentRecord) toModel() models.CarDocument {
|
||||
return models.CarDocument{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Type: rec.Type,
|
||||
Title: rec.Title,
|
||||
Provider: rec.Provider,
|
||||
Reference: rec.Reference,
|
||||
IssueDate: parsePBDatePtr(rec.IssueDate),
|
||||
ExpiryDate: parsePBDatePtr(rec.ExpiryDate),
|
||||
Cost: rec.Cost,
|
||||
Notes: rec.Notes,
|
||||
FileName: rec.File,
|
||||
HasFile: rec.File != "",
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// documentPayload omits the file field: attachments move over multipart, never
|
||||
// as JSON, so a metadata write must not blank an existing upload.
|
||||
func documentPayload(d models.CarDocument) map[string]any {
|
||||
return map[string]any{
|
||||
"car": d.Car,
|
||||
"type": d.Type,
|
||||
"title": d.Title,
|
||||
"provider": d.Provider,
|
||||
"reference": d.Reference,
|
||||
"issue_date": formatPBDatePtr(d.IssueDate),
|
||||
"expiry_date": formatPBDatePtr(d.ExpiryDate),
|
||||
"cost": d.Cost,
|
||||
"notes": d.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders ---
|
||||
|
||||
type reminderRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
DueDate string `json:"due_date"`
|
||||
DueKm int `json:"due_km"`
|
||||
RepeatDays int `json:"repeat_days"`
|
||||
RepeatKm int `json:"repeat_km"`
|
||||
Done bool `json:"done"`
|
||||
DoneAt string `json:"done_at"`
|
||||
Notes string `json:"notes"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec reminderRecord) toModel() models.Reminder {
|
||||
return models.Reminder{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Title: rec.Title,
|
||||
Type: rec.Type,
|
||||
DueDate: parsePBDatePtr(rec.DueDate),
|
||||
DueKm: rec.DueKm,
|
||||
RepeatDays: rec.RepeatDays,
|
||||
RepeatKm: rec.RepeatKm,
|
||||
Done: rec.Done,
|
||||
DoneAt: parsePBDatePtr(rec.DoneAt),
|
||||
Notes: rec.Notes,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func reminderPayload(r models.Reminder) map[string]any {
|
||||
return map[string]any{
|
||||
"car": r.Car,
|
||||
"title": r.Title,
|
||||
"type": r.Type,
|
||||
"due_date": formatPBDatePtr(r.DueDate),
|
||||
"due_km": r.DueKm,
|
||||
"repeat_days": r.RepeatDays,
|
||||
"repeat_km": r.RepeatKm,
|
||||
"done": r.Done,
|
||||
"done_at": formatPBDatePtr(r.DoneAt),
|
||||
"notes": r.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
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. The target is
|
||||
// only a fallback for a car whose odometer is untracked (0), where
|
||||
// rolling from zero would put the next due date in the past.
|
||||
base := car.CurrentKm
|
||||
if base <= 0 {
|
||||
base = m.DueKm
|
||||
}
|
||||
payload["due_km"] = base + 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 ""
|
||||
}
|
||||
@@ -53,6 +53,34 @@
|
||||
// DELETE /api/service-records/{id}
|
||||
// GET /api/parts POST /api/parts
|
||||
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
|
||||
//
|
||||
// # fuel tracking (efficiency is derived on read, never stored)
|
||||
// GET /api/cars/{id}/fuel-entries
|
||||
// GET /api/cars/{id}/fuel-stats
|
||||
// GET /api/fuel-entries POST /api/fuel-entries
|
||||
// GET /api/fuel-entries/{id} PATCH /api/fuel-entries/{id}
|
||||
// DELETE /api/fuel-entries/{id}
|
||||
//
|
||||
// # maintenance log (workshop visits + repairs; distinct from service records)
|
||||
// GET /api/cars/{id}/maintenance
|
||||
// GET /api/maintenance POST /api/maintenance
|
||||
// GET /api/maintenance/{id} PATCH /api/maintenance/{id}
|
||||
// DELETE /api/maintenance/{id}
|
||||
//
|
||||
// # document tracking (insurance, pollution certs, … + renewal dates)
|
||||
// GET /api/cars/{id}/documents
|
||||
// GET /api/car-documents POST /api/car-documents
|
||||
// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
|
||||
// DELETE /api/car-documents/{id}
|
||||
// POST /api/car-documents/{id}/file
|
||||
// GET /api/car-documents/{id}/file
|
||||
// DELETE /api/car-documents/{id}/file
|
||||
//
|
||||
// # reminders (stored + auto-derived from documents and service records)
|
||||
// GET /api/cars/{id}/reminders
|
||||
// GET /api/reminders POST /api/reminders
|
||||
// GET /api/reminders/{id} PATCH /api/reminders/{id}
|
||||
// DELETE /api/reminders/{id} POST /api/reminders/{id}/complete
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -70,11 +98,15 @@ import (
|
||||
|
||||
// PocketBase collection names.
|
||||
const (
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
colFuel = "fuel_entries"
|
||||
colMaintenance = "maintenance_entries"
|
||||
colDocuments = "car_documents"
|
||||
colReminders = "reminders"
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
@@ -207,6 +239,11 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
||||
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
||||
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
|
||||
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
|
||||
mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
|
||||
mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
|
||||
mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
|
||||
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
||||
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
||||
@@ -225,6 +262,39 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
|
||||
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
|
||||
|
||||
// Fuel entries.
|
||||
mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries)
|
||||
mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry)
|
||||
mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry)
|
||||
mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry)
|
||||
mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry)
|
||||
|
||||
// Maintenance log.
|
||||
mux.HandleFunc("GET /api/maintenance", s.listMaintenance)
|
||||
mux.HandleFunc("POST /api/maintenance", s.createMaintenance)
|
||||
mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance)
|
||||
mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance)
|
||||
mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance)
|
||||
|
||||
// Documents. Named /api/car-documents so the path can't be mistaken for the
|
||||
// user-facing account documents some other Vault services expose.
|
||||
mux.HandleFunc("GET /api/car-documents", s.listDocuments)
|
||||
mux.HandleFunc("POST /api/car-documents", s.createDocument)
|
||||
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
|
||||
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
|
||||
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
|
||||
mux.HandleFunc("POST /api/car-documents/{id}/file", s.handleUploadDocumentFile)
|
||||
mux.HandleFunc("GET /api/car-documents/{id}/file", s.handleGetDocumentFile)
|
||||
mux.HandleFunc("DELETE /api/car-documents/{id}/file", s.handleDeleteDocumentFile)
|
||||
|
||||
// Reminders.
|
||||
mux.HandleFunc("GET /api/reminders", s.listReminders)
|
||||
mux.HandleFunc("POST /api/reminders", s.createReminder)
|
||||
mux.HandleFunc("GET /api/reminders/{id}", s.getReminder)
|
||||
mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder)
|
||||
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
|
||||
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
|
||||
|
||||
return s.withMiddleware(mux)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user