Files
DriverVault/API Server/internal/api/records.go
T
tajniak81andClaude Opus 4.8 07192f1238 Add attachments to service, maintenance, fuel and parts records
Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.

Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.

Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
  "Oil & filter"), and the form and table now agree

The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:41:01 +02:00

453 lines
13 KiB
Go

package api
import (
"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"`
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"`
Owner string `json:"owner"`
Created string `json:"created"`
Updated string `json:"updated"`
}
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,
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,
Owner: rec.Owner,
Created: rec.Created,
Updated: rec.Updated,
}
}
// carPayload builds the write payload for create/update from a domain Car.
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,
"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,
}
}
// --- 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,
}
}
// --- 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,
}
}