Files
DriverVault/API Server/internal/api/records.go
T
tajniak81andClaude Opus 5 bc798dae49 Cars: drag the Information rows into the order you want
The rows on a car's Information tab now take a drag: they reorder as the
pointer crosses them and the arrangement saves on drop — or on dragend,
since a row released in the gap between rows never produces a drop and
would otherwise revert on the next load. Same native drag events as the
garage, so also pointer-only, and it needs write access.

The order belongs to the car, like the choice of which rows show at all,
so everyone it is shared with sees the same page. It is stored as the
full list of the 14 keys, hidden rows included: a row switched off and
back on returns to where it was rather than to the end. A key the stored
arrangement doesn't mention — a row added in a later release — follows
the arranged ones, the same rule the garage uses for a car added since
the last drag.

fieldOrder rides on the existing PUT /api/cars/{id}/view, which writes
only the lists it is given, so a drag never has to resend what is hidden.
A partial arrangement is accepted; an invented key is still a 400, which
is why normalizeHidden is now normalizeKeys — it validates an order as
well as a switched-off set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:52:21 +02:00

529 lines
17 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, plus the arrangement of the
// Information rows. 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"`
FieldOrder json.RawMessage `json:"field_order"`
}
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),
FieldOrder: decodeStringList(rec.FieldOrder),
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,
}
}