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, Information fields, Service history columns and service // parts, plus // the arrangements of the tabs, the Information rows, those columns and the // connected service's readings. 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"` HiddenServiceColumns json.RawMessage `json:"hidden_service_columns"` HiddenServiceParts json.RawMessage `json:"hidden_service_parts"` TabOrder json.RawMessage `json:"tab_order"` FieldOrder json.RawMessage `json:"field_order"` ServiceColumnOrder json.RawMessage `json:"service_column_order"` MetricOrder json.RawMessage `json:"metric_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), HiddenServiceColumns: decodeStringList(rec.HiddenServiceColumns), HiddenServiceParts: decodeStringList(rec.HiddenServiceParts), TabOrder: decodeStringList(rec.TabOrder), FieldOrder: decodeStringList(rec.FieldOrder), ServiceColumnOrder: decodeStringList(rec.ServiceColumnOrder), MetricOrder: decodeStringList(rec.MetricOrder), 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, } } // --- charging sessions --- type chargingRecord struct { ID string `json:"id"` Car string `json:"car"` Date string `json:"date"` Km int `json:"km"` Kwh float64 `json:"kwh"` Cost float64 `json:"cost"` FullCharge bool `json:"full_charge"` MissedSession bool `json:"missed_session"` Location string `json:"location"` Notes string `json:"notes"` File string `json:"file"` Created string `json:"created"` Updated string `json:"updated"` } func (rec chargingRecord) toModel() models.ChargingSession { return models.ChargingSession{ ID: rec.ID, Car: rec.Car, Date: parsePBDate(rec.Date), Km: rec.Km, Kwh: rec.Kwh, Cost: rec.Cost, FullCharge: rec.FullCharge, MissedSession: rec.MissedSession, Location: rec.Location, Notes: rec.Notes, Attachment: attachmentOf(rec.File), Created: rec.Created, Updated: rec.Updated, } } // chargingPayload omits the file field — see attachmentOf. func chargingPayload(c models.ChargingSession) map[string]any { return map[string]any{ "car": c.Car, "date": formatPBDate(c.Date), "km": c.Km, "kwh": c.Kwh, "cost": c.Cost, "full_charge": c.FullCharge, "missed_session": c.MissedSession, "location": c.Location, "notes": c.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, } }