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:
tajniak81
2026-07-17 09:50:11 +02:00
co-authored by Claude Opus 4.8
parent ae6ed4ac1e
commit e64c89a564
15 changed files with 3358 additions and 39 deletions
+327
View File
@@ -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 ""
}
+250
View File
@@ -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)
}
+210
View File
@@ -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 ""
}
+235
View File
@@ -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,
}
}
+405
View File
@@ -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 ""
}
+75 -5
View File
@@ -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)
}
+374
View File
@@ -84,6 +84,168 @@ type Part struct {
Updated string `json:"updated,omitempty"`
}
// FuelEntry is one refuelling stop for a car.
//
// Efficiency is derived by the full-tank method rather than stored: a tank
// filled to the brim is a known reference point, so the fuel burned between two
// consecutive full tanks is exactly what was poured in over that span. Partial
// fills in between are not measurable on their own — they roll into the next
// full tank's window. See ComputeFuelDerived.
type FuelEntry struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Date time.Time `json:"date"` // date of the refill
Km int `json:"km"` // odometer at the pump
Liters float64 `json:"liters"`
Cost float64 `json:"cost"` // total paid for this fill
// FullTank marks a fill to the brim — the reference point efficiency windows
// are measured between.
FullTank bool `json:"fullTank"`
// MissedFill records that a refill happened before this one without being
// logged. The odometer span is then not accounted for by the litres on
// record, so any window containing it is left uncomputed rather than
// reported as an implausibly good figure.
MissedFill bool `json:"missedFill"`
Station string `json:"station,omitempty"`
Notes string `json:"notes,omitempty"`
// Derived (not stored): filled in by the API on read.
PricePerLiter *float64 `json:"pricePerLiter,omitempty"`
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full tank
LitersUsed *float64 `json:"litersUsed,omitempty"` // litres burned over that distance
ConsumptionL100 *float64 `json:"consumptionL100,omitempty"` // litres per 100 km
KmPerLiter *float64 `json:"kmPerLiter,omitempty"`
CostPerKm *float64 `json:"costPerKm,omitempty"`
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// FuelStats summarises a car's whole refill history.
type FuelStats struct {
Entries int `json:"entries"`
TotalLiters float64 `json:"totalLiters"`
TotalCost float64 `json:"totalCost"`
// TrackedDistanceKm is the distance covered by computable full-tank windows,
// which is less than the odometer span whenever the history starts or ends
// on a partial fill. The averages below describe exactly this distance.
TrackedDistanceKm int `json:"trackedDistanceKm"`
AvgConsumptionL100 *float64 `json:"avgConsumptionL100,omitempty"`
BestConsumptionL100 *float64 `json:"bestConsumptionL100,omitempty"`
WorstConsumptionL100 *float64 `json:"worstConsumptionL100,omitempty"`
AvgKmPerLiter *float64 `json:"avgKmPerLiter,omitempty"`
AvgPricePerLiter *float64 `json:"avgPricePerLiter,omitempty"`
CostPerKm *float64 `json:"costPerKm,omitempty"`
FirstDate *time.Time `json:"firstDate,omitempty"`
LastDate *time.Time `json:"lastDate,omitempty"`
}
// MaintenanceEntry is one workshop visit or repair — work done on the car
// outside the routine service schedule (which lives in ServiceRecord). A broken
// alternator replaced at a garage belongs here; the annual oil change does not.
type MaintenanceEntry struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Date time.Time `json:"date"` // date of the visit
Km int `json:"km"` // odometer at the visit
Type string `json:"type"` // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
Status string `json:"status"` // scheduled|in_progress|completed
Workshop string `json:"workshop"` // garage/workshop name
Location string `json:"location"` // optional: city or address
Description string `json:"description"` // what was done
PartsUsed string `json:"partsUsed"` // free-text list of parts replaced
LaborCost float64 `json:"laborCost"`
PartsCost float64 `json:"partsCost"`
InvoiceNumber string `json:"invoiceNumber,omitempty"`
WarrantyUntil *time.Time `json:"warrantyUntil,omitempty"`
Notes string `json:"notes,omitempty"`
// Derived (not stored): filled in by the API on read.
TotalCost float64 `json:"totalCost"`
WarrantyActive *bool `json:"warrantyActive,omitempty"`
WarrantyDaysLeft *int `json:"warrantyDaysLeft,omitempty"`
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// CarDocument is a piece of paperwork tied to a car — insurance policies,
// pollution/emissions certificates, registration papers, and so on. The renewal
// date is the point of the whole record: an expired policy is a car that cannot
// legally be driven, so Expiry is computed live on every read.
type CarDocument struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Type string `json:"type"` // insurance|pollution|registration|inspection|roadTax|warranty|other
Title string `json:"title"` // e.g. "Third-party liability 2026"
Provider string `json:"provider,omitempty"` // insurer / issuing authority
Reference string `json:"reference,omitempty"` // policy or certificate number
IssueDate *time.Time `json:"issueDate,omitempty"`
ExpiryDate *time.Time `json:"expiryDate,omitempty"` // blank = never expires
Cost float64 `json:"cost"`
Notes string `json:"notes,omitempty"`
// FileName is the stored attachment (scan/PDF), served via
// GET /api/car-documents/{id}/file. Empty when nothing is attached.
FileName string `json:"fileName,omitempty"`
HasFile bool `json:"hasFile"`
// Derived (not stored): filled in by the API on read.
Expiry ExpiryAssessment `json:"expiry"`
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// ExpiryAssessment is the server-computed lifecycle state of a dated document.
type ExpiryAssessment struct {
State string `json:"state"` // no_expiry | valid | expiring_soon | expired
Days *int `json:"daysUntilExpiry"` // nil when there is no expiry date
}
// Reminder is something the user wants to be told about: a booked workshop slot,
// an insurance renewal, a tyre swap. A reminder fires on a date, an odometer
// reading, or both — whichever comes first.
type Reminder struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Title string `json:"title"`
Type string `json:"type"` // maintenance|document|service|inspection|other
DueDate *time.Time `json:"dueDate,omitempty"`
DueKm int `json:"dueKm,omitempty"`
// RepeatDays/RepeatKm turn a reminder into a recurring one: completing it
// rolls the trigger forward by this much instead of closing it out.
RepeatDays int `json:"repeatDays,omitempty"`
RepeatKm int `json:"repeatKm,omitempty"`
Done bool `json:"done"`
DoneAt *time.Time `json:"doneAt,omitempty"`
Notes string `json:"notes,omitempty"`
// Derived (not stored): filled in by the API on read.
Status string `json:"status"` // done | overdue | due_soon | upcoming | no_trigger
DaysLeft *int `json:"daysLeft,omitempty"` // nil when there is no due date
KmLeft *int `json:"kmLeft,omitempty"` // nil when there is no due km / no odometer
Auto bool `json:"auto"` // true = derived from a document/service, read-only
SourceRef string `json:"sourceRef,omitempty"` // id of the record an auto reminder came from
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// User is the authenticated account's profile, covering the Settings panel's
// Account/Profile/Appearance sections.
type User struct {
@@ -138,3 +300,215 @@ func (r *ServiceRecord) ComputeDerived(c *Car) {
r.NextServiceKm = &n
}
}
// SoonDays is the window within which an upcoming expiry or reminder is
// surfaced as "due soon" rather than merely upcoming.
const SoonDays = 30
// soonKm mirrors SoonDays for odometer-triggered reminders.
const soonKm = 1000
// ComputeFuelDerived fills the derived efficiency fields on a car's refill
// history. `entries` must be ordered oldest-first by odometer.
//
// The full-tank method: between two consecutive full tanks the car burned
// exactly the fuel added over that span, because both endpoints are the same
// known level. Everything poured in after the earlier full tank up to and
// including the later one counts, which is what folds partial fills into the
// window that closes them. A window is left uncomputed when a fill inside it is
// flagged MissedFill, when the odometer did not advance, or when no litres were
// recorded — reporting a figure there would be fiction.
func ComputeFuelDerived(entries []FuelEntry) {
for i := range entries {
if entries[i].Liters > 0 && entries[i].Cost > 0 {
p := entries[i].Cost / entries[i].Liters
entries[i].PricePerLiter = &p
}
}
lastFull := -1
for i := range entries {
if !entries[i].FullTank {
continue
}
if lastFull < 0 {
// First full tank: nothing before it to measure against.
lastFull = i
continue
}
dist := entries[i].Km - entries[lastFull].Km
liters, cost := 0.0, 0.0
usable := true
for j := lastFull + 1; j <= i; j++ {
if entries[j].MissedFill {
usable = false
}
liters += entries[j].Liters
cost += entries[j].Cost
}
if usable && dist > 0 && liters > 0 {
d, l := dist, liters
entries[i].DistanceKm = &d
entries[i].LitersUsed = &l
l100 := liters / float64(dist) * 100
entries[i].ConsumptionL100 = &l100
kmpl := float64(dist) / liters
entries[i].KmPerLiter = &kmpl
if cost > 0 {
cpk := cost / float64(dist)
entries[i].CostPerKm = &cpk
}
}
lastFull = i
}
}
// ComputeFuelStats summarises a refill history whose derived fields have already
// been filled in by ComputeFuelDerived. `entries` must be ordered oldest-first.
//
// Averages are distance-weighted — total litres over total distance across every
// computable window — rather than a mean of the per-window figures, so a long
// motorway run counts for more than a short trip across town, which is what
// actually happened to the fuel.
func ComputeFuelStats(entries []FuelEntry) FuelStats {
st := FuelStats{Entries: len(entries)}
if len(entries) == 0 {
return st
}
var windowLiters, windowCost float64
for i := range entries {
e := &entries[i]
st.TotalLiters += e.Liters
st.TotalCost += e.Cost
if e.ConsumptionL100 == nil {
continue
}
st.TrackedDistanceKm += *e.DistanceKm
windowLiters += *e.LitersUsed
if e.CostPerKm != nil {
windowCost += *e.CostPerKm * float64(*e.DistanceKm)
}
if st.BestConsumptionL100 == nil || *e.ConsumptionL100 < *st.BestConsumptionL100 {
v := *e.ConsumptionL100
st.BestConsumptionL100 = &v
}
if st.WorstConsumptionL100 == nil || *e.ConsumptionL100 > *st.WorstConsumptionL100 {
v := *e.ConsumptionL100
st.WorstConsumptionL100 = &v
}
}
if st.TrackedDistanceKm > 0 && windowLiters > 0 {
avg := windowLiters / float64(st.TrackedDistanceKm) * 100
st.AvgConsumptionL100 = &avg
kmpl := float64(st.TrackedDistanceKm) / windowLiters
st.AvgKmPerLiter = &kmpl
if windowCost > 0 {
cpk := windowCost / float64(st.TrackedDistanceKm)
st.CostPerKm = &cpk
}
}
if st.TotalLiters > 0 && st.TotalCost > 0 {
ppl := st.TotalCost / st.TotalLiters
st.AvgPricePerLiter = &ppl
}
first, last := entries[0].Date, entries[len(entries)-1].Date
if !first.IsZero() {
st.FirstDate = &first
}
if !last.IsZero() {
st.LastDate = &last
}
return st
}
// ComputeMaintenanceDerived fills the derived cost and warranty fields.
func (m *MaintenanceEntry) ComputeMaintenanceDerived(now time.Time) {
m.TotalCost = m.LaborCost + m.PartsCost
if m.WarrantyUntil == nil || m.WarrantyUntil.IsZero() {
return
}
days := daysBetween(now, *m.WarrantyUntil)
active := days >= 0
m.WarrantyActive = &active
m.WarrantyDaysLeft = &days
}
// ComputeExpiry classifies a document by its expiry date relative to `now`.
func (d *CarDocument) ComputeExpiry(now time.Time) {
if d.ExpiryDate == nil || d.ExpiryDate.IsZero() {
d.Expiry = ExpiryAssessment{State: "no_expiry"}
return
}
days := daysBetween(now, *d.ExpiryDate)
state := "valid"
switch {
case days < 0:
state = "expired"
case days <= SoonDays:
state = "expiring_soon"
}
d.Expiry = ExpiryAssessment{State: state, Days: &days}
}
// ComputeReminderDerived resolves a reminder's status against today's date and
// the car's current odometer. A reminder with both triggers fires on whichever
// arrives first, so the worse of the two signals wins.
func (r *Reminder) ComputeReminderDerived(now time.Time, currentKm int) {
if r.Done {
r.Status = "done"
return
}
rank := map[string]int{"no_trigger": 0, "upcoming": 1, "due_soon": 2, "overdue": 3}
status := "no_trigger"
worsen := func(s string) {
if rank[s] > rank[status] {
status = s
}
}
if r.DueDate != nil && !r.DueDate.IsZero() {
days := daysBetween(now, *r.DueDate)
r.DaysLeft = &days
switch {
case days < 0:
worsen("overdue")
case days <= SoonDays:
worsen("due_soon")
default:
worsen("upcoming")
}
}
if r.DueKm > 0 && currentKm > 0 {
left := r.DueKm - currentKm
r.KmLeft = &left
switch {
case left < 0:
worsen("overdue")
case left <= soonKm:
worsen("due_soon")
default:
worsen("upcoming")
}
}
r.Status = status
}
// daysBetween returns whole days from `now` to `target`, both truncated to the
// day, so a deadline later today reads as 0 rather than a fraction.
func daysBetween(now, target time.Time) int {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
t := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC)
return int(t.Sub(today).Hours() / 24)
}
+125 -7
View File
@@ -1,9 +1,12 @@
// Idempotent PocketBase schema setup for the Car Control project.
//
// Creates three collections cars, service_records, parts — matching the
// original "Car Service.xlsx". Access rules are left admin-only (null) on
// purpose: every client goes through the API Server, which authenticates as a
// superuser, so the database is never exposed directly.
// Creates the collections behind the app: cars, service_records and parts (which
// match the original "Car Service.xlsx"), the sharing/tenancy tables, and the
// fuel, maintenance, document and reminder logs layered on top. Access rules are
// left admin-only (null) on purpose: every client goes through the API Server,
// which authenticates as a superuser, so the database is never exposed directly
// — including document attachments, which are proxied by the API rather than
// served as public file URLs.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
@@ -70,6 +73,8 @@ const F = {
relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }),
select: (name, values, required = false) => ({ name, type: "select", required, values }),
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
// Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
};
function renderField(def, format, idByName) {
@@ -86,6 +91,11 @@ function renderField(def, format, idByName) {
options.values = def.values;
options.maxSelect = 1;
}
if (def.type === "file") {
options.maxSelect = 1;
options.maxSize = def.maxSize;
options.mimeTypes = def.mimeTypes || [];
}
return { name: def.name, type: def.type, required: def.required, options };
}
// Modern: options flattened onto the field.
@@ -104,6 +114,11 @@ function renderField(def, format, idByName) {
field.onCreate = def.onCreate;
field.onUpdate = def.onUpdate;
}
if (def.type === "file") {
field.maxSelect = 1;
field.maxSize = def.maxSize;
field.mimeTypes = def.mimeTypes || [];
}
return field;
}
@@ -238,6 +253,81 @@ const DESIRED = {
F.text("part_number"),
F.text("category"),
],
// Fuel refills. Consumption is NOT stored — the API derives it from the whole
// history on read (models.ComputeFuelDerived), so correcting an old fill fixes
// every figure it affects with no rows to migrate.
fuel_entries: [
F.relation("car", "cars", true),
F.date("date", true),
F.number("km"), // odometer at the pump
F.number("liters"),
F.number("cost"),
// Filled to the brim — the reference point efficiency is measured between.
F.bool("full_tank"),
// A refill happened before this one without being logged, so any window
// containing it is left uncomputed rather than reported as implausibly good.
F.bool("missed_fill"),
F.text("station"),
F.text("notes"),
],
// 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/one-off garage work with a labour bill.
maintenance_entries: [
F.relation("car", "cars", true),
F.date("date", true),
F.number("km"),
F.select("type", ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"]),
F.select("status", ["scheduled", "in_progress", "completed"]),
F.text("workshop"),
F.text("location"),
F.text("description"),
F.text("parts_used"),
F.number("labor_cost"),
F.number("parts_cost"),
F.text("invoice_number"),
F.date("warranty_until"),
F.text("notes"),
],
// Insurance, pollution certificates, registration papers … The expiry date is
// the point of the record: it drives the renewal status badges and the
// auto-derived reminders. Blank expiry = never expires.
car_documents: [
F.relation("car", "cars", true),
F.select("type", ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"]),
F.text("title", true),
F.text("provider"),
F.text("reference"),
F.date("issue_date"),
F.date("expiry_date"),
F.number("cost"),
F.text("notes"),
// The scan/PDF. 10MB cap, matching maxDocumentUpload in the API server.
// Reached only via the API's own file endpoint, never as a public URL.
F.file("file", 10485760, [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
]),
],
// User-set reminders. The API additionally synthesises read-only ones from
// document expiry dates and the next service due — those are derived on read
// and have no rows here.
reminders: [
F.relation("car", "cars", true),
F.text("title", true),
F.select("type", ["maintenance", "document", "service", "inspection", "other"]),
F.date("due_date"),
F.number("due_km"),
// Non-zero => recurring: completing rolls the trigger forward by this much.
F.number("repeat_days"),
F.number("repeat_km"),
F.bool("done"),
F.date("done_at"),
F.text("notes"),
],
// Per-car sharing grants. One row = "this user may access this car" at the
// given permission. Cascades on both relations so grants disappear when
// either the car or the user is deleted. (Owner access is NOT stored here —
@@ -276,6 +366,12 @@ const DESIRED = {
// unique so the API can rely on PocketBase rejecting a duplicate.
const INDEXES = {
organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"],
// Every read of these is "…for this car", and the fuel history is walked in
// odometer order to build its efficiency windows.
fuel_entries: ["CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"],
maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"],
car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"],
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
};
async function main() {
@@ -293,7 +389,17 @@ async function main() {
// Create in dependency order (organizations before users references it; cars
// before its relations; "users" already exists as PocketBase's built-in auth
// collection, so it's never created here — only reconciled below).
for (const name of ["organizations", "cars", "service_records", "parts", "car_shares"]) {
for (const name of [
"organizations",
"cars",
"service_records",
"parts",
"car_shares",
"fuel_entries",
"maintenance_entries",
"car_documents",
"reminders",
]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
console.log(`${name} — created`);
@@ -305,12 +411,24 @@ async function main() {
// Reconcile fields on existing collections (add missing + fix relation options
// and select values — this is what grows users.role to include "superadmin"
// and adds users.organization on an existing deployment).
for (const name of ["organizations", "users", "cars", "service_records", "parts", "car_shares"]) {
for (const name of [
"organizations",
"users",
"cars",
"service_records",
"parts",
"car_shares",
"fuel_entries",
"maintenance_entries",
"car_documents",
"reminders",
]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
console.log(
"\nDone. Collections ready: organizations, users, cars, service_records, parts, car_shares.",
"\nDone. Collections ready: organizations, users, cars, service_records, parts,\n" +
"car_shares, fuel_entries, maintenance_entries, car_documents, reminders.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +