Add technical check history

A car's mandatory roadworthiness inspections — przegląd techniczny, MOT,
TÜV — had nowhere to live. Log them behind Service history, which this
mirrors minus the odometer: an inspection falls due on a date whatever
the mileage reads.

The cycle is legal rather than mechanical, and it is not constant — a new
car's first check falls due years later than its third. So the car's
technical_check_interval_days only prefills the next date, and any check
overrides it with the valid-until printed on its own certificate. A
failed check derives no next date at all: reading one off a failure would
stamp a reassuring "valid until" on a car that just flunked.

The next-due date and its status are derived on every read rather than
stored, for the same reason documents are — a stored verdict goes stale
as the date passes. The response carries the same expiry shape documents
use, so the web app reuses the existing badge rather than growing a
second one.

Each check records result, cost, station and notes, and holds the
certificate as its single attachment on the same terms as every other
record.

Needs `node scripts/setup-pocketbase.mjs` to create technical_checks and
add the interval to cars. The reformatting in records.go is gofmt
realigning the car struct around a longer field name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-17 11:54:26 +02:00
co-authored by Claude Opus 4.8
parent 03738f08dc
commit 21c99ad762
11 changed files with 713 additions and 81 deletions
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"drivervault/apiserver/internal/models"
)
// Technical check history: the mandatory roadworthiness inspections a car has
// been through — przegląd techniczny, MOT, TÜV.
//
// Shaped like the service log next to it, but time-only: an inspection falls due
// on a date regardless of the odometer. The next-due date is assessed live on
// every read (models.TechnicalCheck.ComputeTechnicalCheckDerived) rather than
// stored, for the same reason documents are — a lapsed certificate is a car that
// cannot legally be driven, and a stored verdict would quietly go stale.
//
// The certificate scan is handled by attachments.go, on the same terms as every
// other record's attachment.
var technicalCheckResults = map[string]bool{"passed": true, "failed": true}
// listCarTechnicalChecks serves GET /api/cars/{id}/technical-checks.
func (s *Server) listCarTechnicalChecks(w http.ResponseWriter, r *http.Request) {
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
s.respondTechnicalCheckList(w, r, carID)
}
// listTechnicalChecks serves GET /api/technical-checks?car={id}.
func (s *Server) listTechnicalChecks(w http.ResponseWriter, r *http.Request) {
carID := r.URL.Query().Get("car")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
s.respondTechnicalCheckList(w, r, carID)
}
func (s *Server) respondTechnicalCheckList(w http.ResponseWriter, r *http.Request, carID string) {
q := url.Values{}
q.Set("sort", "-date") // most-recent check first, like the service log
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
res, err := s.pb.List(r.Context(), colTechnicalChecks, q)
if err != nil {
writePBError(w, err)
return
}
var recs []technicalCheckRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cars := newCarCache(s)
now := time.Now()
out := make([]models.TechnicalCheck, 0, len(recs))
for _, rec := range recs {
m := rec.toModel()
car, err := cars.get(r.Context(), m.Car)
if err != nil {
car = nil // fall back to the default interval rather than dropping the row
}
m.ComputeTechnicalCheckDerived(car, now)
out = append(out, m)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) getTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var rec technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
if !s.requireCarAccess(w, r, m.Car, accessRead) {
return
}
s.writeTechnicalCheck(w, r, m, http.StatusOK)
}
func (s *Server) createTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var in models.TechnicalCheck
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Result == "" {
in.Result = "passed"
}
if msg := validateTechnicalCheck(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
return
}
var rec technicalCheckRecord
if err := s.pb.Create(r.Context(), colTechnicalChecks, technicalCheckPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusCreated)
}
func (s *Server) updateTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var in models.TechnicalCheck
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Enforce write access via the record's existing parent car, so the body's
// car field cannot be used to escape into another user's car.
var existing technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
in.Car = existing.Car // a check's car is not reassignable via PATCH
if in.Result == "" {
in.Result = "passed"
}
if msg := validateTechnicalCheck(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var rec technicalCheckRecord
if err := s.pb.Update(r.Context(), colTechnicalChecks, r.PathValue("id"), technicalCheckPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusOK)
}
func (s *Server) deleteTechnicalCheck(w http.ResponseWriter, r *http.Request) {
var existing technicalCheckRecord
if err := s.pb.GetOne(r.Context(), colTechnicalChecks, 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(), colTechnicalChecks, r.PathValue("id")); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeTechnicalCheck derives the next-due date against the parent car and
// responds. Shared by the three handlers that return a single check.
func (s *Server) writeTechnicalCheck(w http.ResponseWriter, r *http.Request, m models.TechnicalCheck, status int) {
car, err := s.carModel(r.Context(), m.Car)
if err != nil {
car = nil
}
m.ComputeTechnicalCheckDerived(car, time.Now())
writeJSON(w, status, m)
}
func validateTechnicalCheck(t models.TechnicalCheck) string {
switch {
case t.Car == "":
return "car is required"
case t.Date.IsZero():
return "date is required"
case !technicalCheckResults[t.Result]:
return "result must be passed or failed"
case t.Cost < 0:
return "cost cannot be negative"
case len(strings.TrimSpace(t.Station)) > 200:
return "station name is too long"
}
if t.ValidUntil != nil && !t.ValidUntil.IsZero() && t.ValidUntil.Before(t.Date) {
return "valid-until date cannot be before the check date"
}
return ""
}