Three things you can now set up rather than live with.
The garage takes a drag: cards reorder as you drag across them and the
arrangement saves on drop — or on dragend, since a card released in the
gap between cards never produces a drop and would otherwise revert on
the next load. It is a per-user list of car ids on the profile, so it
covers cars shared with you and never reorders anybody else's garage;
the API returns /api/cars in that order, so a client only sends the new
one back. Pointer-only: touch browsers don't fire the native drag
events, and this is not worth a dependency.
A car's page is now configurable from the gear in its header: which tabs
it shows, and which of the 14 Information rows. Both belong to the car,
so everyone it is shared with sees the same page — Fuel off on an EV
stays off for all of them — and setting them needs write access. Stored
as the hidden sets, so anything added in a later release is on by
default. PUT /api/cars/{id}/view is its own endpoint precisely so an
ordinary save of the car form, which sends every other field, can never
reveal something that was deliberately switched off. Information itself
can't be hidden: a page with no tabs left would be a dead end.
The connected-service cards fold away, remembered per device, so a
provider that reports eight sections can be trimmed to the two worth
watching. A failed section keeps a short badge in its collapsed header
and puts the provider's own message — a few hundred characters of JSON,
which used to stretch the page sideways — inside the body with
everything else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
527 lines
16 KiB
Go
527 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
)
|
|
|
|
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
|
|
// are tried (in order) when parsing values coming back from PocketBase.
|
|
var pbDateLayouts = []string{
|
|
"2006-01-02 15:04:05.000Z",
|
|
"2006-01-02 15:04:05Z",
|
|
time.RFC3339,
|
|
"2006-01-02",
|
|
}
|
|
|
|
func parsePBDate(s string) time.Time {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}
|
|
}
|
|
for _, l := range pbDateLayouts {
|
|
if t, err := time.Parse(l, s); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// formatPBDate renders a date in the format PocketBase expects on write.
|
|
func formatPBDate(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04:05.000Z")
|
|
}
|
|
|
|
// --- cars ---
|
|
|
|
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
|
|
type carRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Make string `json:"make"`
|
|
Model string `json:"model"`
|
|
Year int `json:"year"`
|
|
Registration string `json:"registration"`
|
|
RegistrationCountry string `json:"registration_country"`
|
|
VIN string `json:"vin"`
|
|
ServiceIntervalDays int `json:"service_interval_days"`
|
|
ServiceIntervalKm int `json:"service_interval_km"`
|
|
TechnicalCheckIntervalDays int `json:"technical_check_interval_days"`
|
|
OilSpec string `json:"oil_spec"`
|
|
TransmissionOilSpec string `json:"transmission_oil_spec"`
|
|
DifferentialOilSpec string `json:"differential_oil_spec"`
|
|
BrakeFluidSpec string `json:"brake_fluid_spec"`
|
|
CoolantSpec string `json:"coolant_spec"`
|
|
CurrentKm int `json:"current_km"`
|
|
FuelType string `json:"fuel_type"`
|
|
BuildDate string `json:"build_date"`
|
|
FirstRegistrationDate string `json:"first_registration_date"`
|
|
Provider string `json:"provider"`
|
|
ProviderVehicleID string `json:"provider_vehicle_id"`
|
|
Owner string `json:"owner"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
|
|
// Switched-off tabs and Information fields. Raw because PocketBase hands back
|
|
// whatever a json field holds — null on a car nobody has configured — which
|
|
// is not a []string.
|
|
HiddenTabs json.RawMessage `json:"hidden_tabs"`
|
|
HiddenFields json.RawMessage `json:"hidden_fields"`
|
|
}
|
|
|
|
func (rec carRecord) toModel() models.Car {
|
|
return models.Car{
|
|
ID: rec.ID,
|
|
Name: rec.Name,
|
|
Make: rec.Make,
|
|
Model: rec.Model,
|
|
Year: rec.Year,
|
|
Registration: rec.Registration,
|
|
RegistrationCountry: rec.RegistrationCountry,
|
|
VIN: rec.VIN,
|
|
ServiceIntervalDays: rec.ServiceIntervalDays,
|
|
ServiceIntervalKm: rec.ServiceIntervalKm,
|
|
TechnicalCheckIntervalDays: rec.TechnicalCheckIntervalDays,
|
|
OilSpec: rec.OilSpec,
|
|
TransmissionOilSpec: rec.TransmissionOilSpec,
|
|
DifferentialOilSpec: rec.DifferentialOilSpec,
|
|
BrakeFluidSpec: rec.BrakeFluidSpec,
|
|
CoolantSpec: rec.CoolantSpec,
|
|
CurrentKm: rec.CurrentKm,
|
|
FuelType: rec.FuelType,
|
|
BuildDate: rec.BuildDate,
|
|
FirstRegistrationDate: rec.FirstRegistrationDate,
|
|
Provider: rec.Provider,
|
|
ProviderVehicleID: rec.ProviderVehicleID,
|
|
HiddenTabs: decodeStringList(rec.HiddenTabs),
|
|
HiddenFields: decodeStringList(rec.HiddenFields),
|
|
Owner: rec.Owner,
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// carPayload builds the write payload for create/update from a domain Car. It
|
|
// deliberately omits owner and the provider link: a car edit must not reassign
|
|
// ownership, and it must not touch the connected-service link either (that is
|
|
// carProviderPayload's job, reached only through the provider endpoints).
|
|
func carPayload(c models.Car) map[string]any {
|
|
return map[string]any{
|
|
"name": c.Name,
|
|
"make": c.Make,
|
|
"model": c.Model,
|
|
"year": c.Year,
|
|
"registration": c.Registration,
|
|
"registration_country": c.RegistrationCountry,
|
|
"vin": c.VIN,
|
|
"service_interval_days": c.ServiceIntervalDays,
|
|
"service_interval_km": c.ServiceIntervalKm,
|
|
"technical_check_interval_days": c.TechnicalCheckIntervalDays,
|
|
"oil_spec": c.OilSpec,
|
|
"transmission_oil_spec": c.TransmissionOilSpec,
|
|
"differential_oil_spec": c.DifferentialOilSpec,
|
|
"brake_fluid_spec": c.BrakeFluidSpec,
|
|
"coolant_spec": c.CoolantSpec,
|
|
"current_km": c.CurrentKm,
|
|
"fuel_type": c.FuelType,
|
|
"build_date": c.BuildDate,
|
|
"first_registration_date": c.FirstRegistrationDate,
|
|
}
|
|
}
|
|
|
|
// carProviderPayload is the connected-service link on its own, so linking and
|
|
// unlinking is a one-field write that leaves the rest of the car alone. An empty
|
|
// provider clears both fields (unlink).
|
|
func carProviderPayload(provider, vehicleID string) map[string]any {
|
|
if provider == "" {
|
|
return map[string]any{"provider": "", "provider_vehicle_id": ""}
|
|
}
|
|
return map[string]any{"provider": provider, "provider_vehicle_id": vehicleID}
|
|
}
|
|
|
|
// --- service records ---
|
|
|
|
type serviceRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Date string `json:"date"`
|
|
Km int `json:"km"`
|
|
ChangedOil bool `json:"changed_oil"`
|
|
ChangedEngineAirFilter bool `json:"changed_engine_air_filter"`
|
|
ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec serviceRecord) toModel() models.ServiceRecord {
|
|
return models.ServiceRecord{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Date: parsePBDate(rec.Date),
|
|
Km: rec.Km,
|
|
ChangedOil: rec.ChangedOil,
|
|
ChangedEngineAirFilter: rec.ChangedEngineAirFilter,
|
|
ChangedCabinAirFilter: rec.ChangedCabinAirFilter,
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// servicePayload omits the file field — see attachmentOf.
|
|
func servicePayload(r models.ServiceRecord) map[string]any {
|
|
return map[string]any{
|
|
"car": r.Car,
|
|
"date": formatPBDate(r.Date),
|
|
"km": r.Km,
|
|
"changed_oil": r.ChangedOil,
|
|
"changed_engine_air_filter": r.ChangedEngineAirFilter,
|
|
"changed_cabin_air_filter": r.ChangedCabinAirFilter,
|
|
"notes": r.Notes,
|
|
}
|
|
}
|
|
|
|
// --- technical checks ---
|
|
|
|
type technicalCheckRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Date string `json:"date"`
|
|
Result string `json:"result"`
|
|
Cost float64 `json:"cost"`
|
|
Station string `json:"station"`
|
|
ValidUntil string `json:"valid_until"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec technicalCheckRecord) toModel() models.TechnicalCheck {
|
|
return models.TechnicalCheck{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Date: parsePBDate(rec.Date),
|
|
Result: rec.Result,
|
|
Cost: rec.Cost,
|
|
Station: rec.Station,
|
|
ValidUntil: parsePBDatePtr(rec.ValidUntil),
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// technicalCheckPayload omits the file field — see attachmentOf.
|
|
func technicalCheckPayload(t models.TechnicalCheck) map[string]any {
|
|
return map[string]any{
|
|
"car": t.Car,
|
|
"date": formatPBDate(t.Date),
|
|
"result": t.Result,
|
|
"cost": t.Cost,
|
|
"station": t.Station,
|
|
"valid_until": formatPBDatePtr(t.ValidUntil),
|
|
"notes": t.Notes,
|
|
}
|
|
}
|
|
|
|
// --- attachments ---
|
|
|
|
// attachmentOf renders a PocketBase file field into the model's attachment pair.
|
|
//
|
|
// It has no counterpart on the write side on purpose: attachments move over
|
|
// multipart via their own endpoint (attachments.go), never as JSON, so a
|
|
// metadata write must not carry a file field that would blank an existing
|
|
// upload.
|
|
func attachmentOf(file string) models.Attachment {
|
|
return models.Attachment{FileName: file, HasFile: file != ""}
|
|
}
|
|
|
|
// --- parts ---
|
|
|
|
type partRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
Name string `json:"name"`
|
|
PartNumber string `json:"part_number"`
|
|
Category string `json:"category"`
|
|
Notes string `json:"notes"`
|
|
File string `json:"file"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
func (rec partRecord) toModel() models.Part {
|
|
return models.Part{
|
|
ID: rec.ID,
|
|
Car: rec.Car,
|
|
Name: rec.Name,
|
|
PartNumber: rec.PartNumber,
|
|
Category: rec.Category,
|
|
Notes: rec.Notes,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// partPayload omits the file field — see attachmentOf.
|
|
func partPayload(p models.Part) map[string]any {
|
|
return map[string]any{
|
|
"car": p.Car,
|
|
"name": p.Name,
|
|
"part_number": p.PartNumber,
|
|
"category": p.Category,
|
|
"notes": p.Notes,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
File string `json:"file"`
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// fuelPayload omits the file field — see attachmentOf.
|
|
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"`
|
|
File string `json:"file"`
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// maintenancePayload omits the file field — see attachmentOf.
|
|
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,
|
|
Attachment: attachmentOf(rec.File),
|
|
Created: rec.Created,
|
|
Updated: rec.Updated,
|
|
}
|
|
}
|
|
|
|
// documentPayload omits the file field — see attachmentOf.
|
|
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,
|
|
}
|
|
}
|