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>
453 lines
13 KiB
Go
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,
|
|
}
|
|
}
|