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>
This commit is contained in:
tajniak81
2026-07-17 10:41:01 +02:00
co-authored by Claude Opus 4.8
parent e64c89a564
commit 07192f1238
15 changed files with 449 additions and 202 deletions
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
)
// Single-file attachments. A document has a scan, a service or a refill has a
// receipt, a workshop visit has an invoice, a catalog part has a photo of the
// box — all the same thing: one optional file hanging off a record that already
// exists.
//
// The terms are identical everywhere, so the endpoints are registered from one
// place (attachmentRoutes) rather than copied per collection. The bytes live in
// PocketBase's file storage and are reached only through this server's superuser
// service account, so an attachment is never a public URL: clients fetch it from
// GET /api/{records}/{id}/file, which re-checks car access on every request.
// maxAttachmentUpload caps an attachment at 10 MB — comfortably above a scanned
// certificate or a photographed receipt, well below anything that would tie up
// the server. Mirrored by the collections' own maxSize in setup-pocketbase.mjs.
const maxAttachmentUpload = 10 << 20
// attachmentFileTypes are the extensions an attachment may carry. The list is
// restrictive on purpose: these are scans and photos, and anything executable
// has no business being stored and handed back out.
var attachmentFileTypes = map[string]bool{
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
}
// attachedRecord is the slice of an attachable record these handlers need: the
// parent car to authorize against, and the stored file name. Every attachable
// collection has both, which is what lets one handler serve all of them.
type attachedRecord struct {
ID string `json:"id"`
Car string `json:"car"`
File string `json:"file"`
}
// attachmentRoutes registers the upload/download/detach endpoints for one
// collection under prefix (e.g. "/api/service-records").
//
// respond is the collection's own GET handler. An upload finishes by delegating
// to it, so the response carries the full record in its own model shape —
// derived fields and all — exactly as a re-read would.
func (s *Server) attachmentRoutes(mux *http.ServeMux, prefix, collection string, respond http.HandlerFunc) {
mux.HandleFunc("POST "+prefix+"/{id}/file", s.handleUploadAttachment(collection, respond))
mux.HandleFunc("GET "+prefix+"/{id}/file", s.handleGetAttachment(collection))
mux.HandleFunc("DELETE "+prefix+"/{id}/file", s.handleDeleteAttachment(collection))
}
// handleUploadAttachment serves POST /api/{records}/{id}/file, replacing
// whatever was attached before.
func (s *Server) handleUploadAttachment(collection string, respond http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
if !ok {
return
}
if err := r.ParseMultipartForm(maxAttachmentUpload); err != nil {
writeError(w, http.StatusBadRequest, "upload must be under 10MB")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxAttachmentUpload+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read upload")
return
}
if len(data) > maxAttachmentUpload {
writeError(w, http.StatusRequestEntityTooLarge, "upload must be under 10MB")
return
}
if msg := validateAttachmentFile(header.Filename); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if err := s.pb.UpdateMultipart(r.Context(), collection, existing.ID, nil, "file", header.Filename, data); err != nil {
writePBError(w, err)
return
}
respond(w, r)
}
}
// handleGetAttachment serves GET /api/{records}/{id}/file. The bytes are proxied
// through this server because PocketBase's collections have no public access
// rules — only the service account can read them.
func (s *Server) handleGetAttachment(collection string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec, ok := s.attachmentTarget(w, r, collection, accessRead)
if !ok {
return
}
if rec.File == "" {
writeError(w, http.StatusNotFound, "no file attached")
return
}
data, contentType, err := s.pb.GetFile(r.Context(), collection, rec.ID, rec.File)
if err != nil {
writePBError(w, err)
return
}
w.Header().Set("Content-Type", contentType)
// The stored name is PocketBase's slugified one; quotes are stripped so a
// crafted filename can't break out of the header.
name := strings.ReplaceAll(rec.File, `"`, "")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
w.Header().Set("Cache-Control", "private, max-age=300")
w.Write(data)
}
}
// handleDeleteAttachment serves DELETE /api/{records}/{id}/file, detaching the
// file but keeping the record itself.
func (s *Server) handleDeleteAttachment(collection string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
if !ok {
return
}
if err := s.pb.Update(r.Context(), collection, existing.ID, map[string]any{"file": nil}, nil); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// attachmentTarget loads the record named by the {id} path value and authorizes
// the caller against its car. It writes the error response itself; ok=false
// means the caller must return without writing anything further.
func (s *Server) attachmentTarget(w http.ResponseWriter, r *http.Request, collection, need string) (attachedRecord, bool) {
var rec attachedRecord
if err := s.pb.GetOne(r.Context(), collection, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return rec, false
}
if !s.requireCarAccess(w, r, rec.Car, need) {
return rec, false
}
return rec, true
}
func validateAttachmentFile(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if !attachmentFileTypes[ext] {
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
}
return ""
}
+2 -123
View File
@@ -3,10 +3,8 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"time"
@@ -22,14 +20,8 @@ import (
// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
// Documents also feed the auto-derived reminders in reminders.go.
//
// The scan/PDF lives in PocketBase's file storage and is reached only through
// this server's superuser service account, so an attachment is never a public
// URL: clients fetch it from GET /api/car-documents/{id}/file, which re-checks
// car access on every request.
// maxDocumentUpload caps an attachment at 10 MB — comfortably above a scanned
// certificate, well below anything that would tie up the server.
const maxDocumentUpload = 10 << 20
// The scan/PDF attached to a document is handled by attachments.go, on the same
// terms as every other record's attachment.
var documentTypes = map[string]bool{
"insurance": true,
@@ -196,104 +188,6 @@ func (s *Server) deleteDocument(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleUploadDocumentFile serves POST /api/car-documents/{id}/file — the
// scan/PDF for an existing document. Replaces whatever was attached before.
func (s *Server) handleUploadDocumentFile(w http.ResponseWriter, r *http.Request) {
var existing documentRecord
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
if err := r.ParseMultipartForm(maxDocumentUpload); err != nil {
writeError(w, http.StatusBadRequest, "document upload must be under 10MB")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxDocumentUpload+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read upload")
return
}
if len(data) > maxDocumentUpload {
writeError(w, http.StatusRequestEntityTooLarge, "document upload must be under 10MB")
return
}
if msg := validateDocumentFile(header.Filename); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if err := s.pb.UpdateMultipart(r.Context(), colDocuments, existing.ID, nil, "file", header.Filename, data); err != nil {
writePBError(w, err)
return
}
var rec documentRecord
if err := s.pb.GetOne(r.Context(), colDocuments, existing.ID, &rec); err != nil {
writePBError(w, err)
return
}
d := rec.toModel()
d.ComputeExpiry(time.Now())
writeJSON(w, http.StatusOK, d)
}
// handleGetDocumentFile serves GET /api/car-documents/{id}/file. The bytes are
// proxied through this server because PocketBase's collections have no public
// access rules — only the service account can read them.
func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
var rec documentRecord
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
return
}
if rec.File == "" {
writeError(w, http.StatusNotFound, "no file attached")
return
}
data, contentType, err := s.pb.GetFile(r.Context(), colDocuments, rec.ID, rec.File)
if err != nil {
writePBError(w, err)
return
}
w.Header().Set("Content-Type", contentType)
// The stored name is PocketBase's slugified one; quotes are stripped so a
// crafted filename can't break out of the header.
name := strings.ReplaceAll(rec.File, `"`, "")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
w.Header().Set("Cache-Control", "private, max-age=300")
w.Write(data)
}
// handleDeleteDocumentFile serves DELETE /api/car-documents/{id}/file, detaching
// the attachment but keeping the document's metadata.
func (s *Server) handleDeleteDocumentFile(w http.ResponseWriter, r *http.Request) {
var existing documentRecord
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
if err := s.pb.Update(r.Context(), colDocuments, existing.ID, map[string]any{"file": nil}, nil); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateDocument(d models.CarDocument) string {
switch {
case d.Car == "":
@@ -310,18 +204,3 @@ func validateDocument(d models.CarDocument) string {
}
return ""
}
// documentFileTypes are the extensions an attachment may carry. The list is
// restrictive on purpose: these documents are scans, and anything executable has
// no business being stored and handed back out.
var documentFileTypes = map[string]bool{
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
}
func validateDocumentFile(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if !documentFileTypes[ext] {
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
}
return ""
}
+29 -4
View File
@@ -127,6 +127,7 @@ type serviceRecord struct {
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"`
}
@@ -141,11 +142,13 @@ func (rec serviceRecord) toModel() models.ServiceRecord {
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,
@@ -158,6 +161,18 @@ func servicePayload(r models.ServiceRecord) map[string]any {
}
}
// --- 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 {
@@ -166,6 +181,8 @@ type partRecord struct {
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"`
}
@@ -177,17 +194,21 @@ func (rec partRecord) toModel() models.Part {
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,
}
}
@@ -224,6 +245,7 @@ type fuelRecord struct {
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"`
}
@@ -240,11 +262,13 @@ func (rec fuelRecord) toModel() models.FuelEntry {
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,
@@ -277,6 +301,7 @@ type maintenanceRecord struct {
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"`
}
@@ -298,11 +323,13 @@ func (rec maintenanceRecord) toModel() models.MaintenanceEntry {
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,
@@ -352,15 +379,13 @@ func (rec documentRecord) toModel() models.CarDocument {
ExpiryDate: parsePBDatePtr(rec.ExpiryDate),
Cost: rec.Cost,
Notes: rec.Notes,
FileName: rec.File,
HasFile: rec.File != "",
Attachment: attachmentOf(rec.File),
Created: rec.Created,
Updated: rec.Updated,
}
}
// documentPayload omits the file field: attachments move over multipart, never
// as JSON, so a metadata write must not blank an existing upload.
// documentPayload omits the file field — see attachmentOf.
func documentPayload(d models.CarDocument) map[string]any {
return map[string]any{
"car": d.Car,
+15 -6
View File
@@ -72,9 +72,12 @@
// GET /api/car-documents POST /api/car-documents
// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
// DELETE /api/car-documents/{id}
// POST /api/car-documents/{id}/file
// GET /api/car-documents/{id}/file
// DELETE /api/car-documents/{id}/file
//
// # attachments — one optional file per record, same three verbs everywhere.
// # {records} is car-documents | service-records | maintenance | fuel-entries | parts
// POST /api/{records}/{id}/file
// GET /api/{records}/{id}/file
// DELETE /api/{records}/{id}/file
//
// # reminders (stored + auto-derived from documents and service records)
// GET /api/cars/{id}/reminders
@@ -283,9 +286,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
mux.HandleFunc("POST /api/car-documents/{id}/file", s.handleUploadDocumentFile)
mux.HandleFunc("GET /api/car-documents/{id}/file", s.handleGetDocumentFile)
mux.HandleFunc("DELETE /api/car-documents/{id}/file", s.handleDeleteDocumentFile)
// Reminders.
mux.HandleFunc("GET /api/reminders", s.listReminders)
@@ -295,6 +295,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
// Attachments — one optional file per record, on identical terms for every
// collection that takes one (see attachments.go). The GET handler passed
// alongside is what renders the record after an upload.
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
return s.withMiddleware(mux)
}
+27 -4
View File
@@ -10,6 +10,18 @@ package models
import "time"
// Attachment is the single optional file a record carries — a scan, a receipt, a
// workshop invoice, a photo of a part's box. Embedded by every type that can
// hold one.
//
// FileName is the name PocketBase stored it under. The bytes are not in here:
// they are served from GET /api/{records}/{id}/file, which re-checks access on
// every request, so an attachment is never a public URL.
type Attachment struct {
FileName string `json:"fileName,omitempty"`
HasFile bool `json:"hasFile"`
}
// Car corresponds to one worksheet in the original spreadsheet.
type Car struct {
ID string `json:"id"`
@@ -64,6 +76,9 @@ type ServiceRecord struct {
Notes string `json:"notes,omitempty"`
// The workshop receipt or stamped service-book page for this visit.
Attachment
// Derived (not stored): filled in by the API on read.
NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C
NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D
@@ -79,6 +94,10 @@ type Part struct {
Name string `json:"name"` // e.g. "Oil Filter"
PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7"
Category string `json:"category"` // optional: oil|filter|wiper|other
Notes string `json:"notes,omitempty"`
// A photo of the box, or the spec sheet for the part.
Attachment
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
@@ -112,6 +131,9 @@ type FuelEntry struct {
Station string `json:"station,omitempty"`
Notes string `json:"notes,omitempty"`
// The pump receipt.
Attachment
// Derived (not stored): filled in by the API on read.
PricePerLiter *float64 `json:"pricePerLiter,omitempty"`
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full tank
@@ -169,6 +191,9 @@ type MaintenanceEntry struct {
WarrantyUntil *time.Time `json:"warrantyUntil,omitempty"`
Notes string `json:"notes,omitempty"`
// The workshop's invoice.
Attachment
// Derived (not stored): filled in by the API on read.
TotalCost float64 `json:"totalCost"`
WarrantyActive *bool `json:"warrantyActive,omitempty"`
@@ -196,10 +221,8 @@ type CarDocument struct {
Cost float64 `json:"cost"`
Notes string `json:"notes,omitempty"`
// FileName is the stored attachment (scan/PDF), served via
// GET /api/car-documents/{id}/file. Empty when nothing is attached.
FileName string `json:"fileName,omitempty"`
HasFile bool `json:"hasFile"`
// The scan or photo of the paperwork itself.
Attachment
// Derived (not stored): filled in by the API on read.
Expiry ExpiryAssessment `json:"expiry"`
+19 -9
View File
@@ -211,6 +211,19 @@ async function reconcileFields(token, name, defs, format, idByName) {
console.log(`${name}${changes.join(", ")}`);
}
// The single optional attachment a record can carry — a scan, a receipt, a
// workshop invoice, a photo of a part. The 10MB cap matches maxAttachmentUpload
// in the API server, and the file is reached only via the API's own file
// endpoint, never as a public URL.
const attachment = () =>
F.file("file", 10485760, [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
]);
// Desired schema. Edit here to evolve collections; re-run the script to apply.
const DESIRED = {
cars: [
@@ -246,12 +259,15 @@ const DESIRED = {
F.bool("changed_engine_air_filter"),
F.bool("changed_cabin_air_filter"),
F.text("notes"),
attachment(), // the workshop receipt / stamped service-book page
],
parts: [
F.relation("car", "cars", true),
F.text("name", true),
F.text("part_number"),
F.text("category"),
F.text("notes"),
attachment(), // a photo of the box, or the part's spec sheet
],
// Fuel refills. Consumption is NOT stored — the API derives it from the whole
// history on read (models.ComputeFuelDerived), so correcting an old fill fixes
@@ -269,6 +285,7 @@ const DESIRED = {
F.bool("missed_fill"),
F.text("station"),
F.text("notes"),
attachment(), // the pump receipt
],
// Workshop visits and repairs. Deliberately separate from service_records:
// that collection is the routine interval schedule (and drives next-service
@@ -288,6 +305,7 @@ const DESIRED = {
F.text("invoice_number"),
F.date("warranty_until"),
F.text("notes"),
attachment(), // the workshop's invoice
],
// Insurance, pollution certificates, registration papers … The expiry date is
// the point of the record: it drives the renewal status badges and the
@@ -302,15 +320,7 @@ const DESIRED = {
F.date("expiry_date"),
F.number("cost"),
F.text("notes"),
// The scan/PDF. 10MB cap, matching maxDocumentUpload in the API server.
// Reached only via the API's own file endpoint, never as a public URL.
F.file("file", 10485760, [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
]),
attachment(), // the scan/PDF of the paperwork itself
],
// User-set reminders. The API additionally synthesises read-only ones from
// document expiry dates and the next service due — those are derived on read