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
+28 -8
View File
@@ -88,6 +88,25 @@ async function requestBlob(path) {
return { blob: await res.blob(), filename };
}
// Attachments. Every record that can carry a file — documents, service records,
// workshop visits, refills, catalog parts — exposes the same three endpoints
// under its own path, so they are built from one place rather than spelled out
// five times.
//
// The file is proxied by the API (PocketBase's files aren't public), so it needs
// the auth header — hence a multipart POST and a Blob fetch rather than a plain
// <a href>. Uploading addresses a record that must already exist; see
// applyAttachment in lib/attachment.js for the order the forms use.
const attachment = (path) => ({
upload: (id, file) => {
const form = new FormData();
form.append("file", file);
return requestForm(`${path}/${id}/file`, { method: "POST", body: form });
},
download: (id) => requestBlob(`${path}/${id}/file`),
remove: (id) => request(`${path}/${id}/file`, { method: "DELETE" }),
});
export const api = {
// Auth
login: (email, password) =>
@@ -145,15 +164,16 @@ export const api = {
updateDocument: (id, body) =>
request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }),
// The attachment is proxied by the API (PocketBase files aren't public), so it
// needs the auth header — hence a Blob fetch rather than a plain link.
uploadDocumentFile: (id, file) => {
const form = new FormData();
form.append("file", file);
return requestForm(`/car-documents/${id}/file`, { method: "POST", body: form });
// Attachments, one per record. Keyed by the same names CarDetail uses for its
// tabs so a table row can reach for the right one generically.
files: {
documents: attachment("/car-documents"),
services: attachment("/service-records"),
maintenance: attachment("/maintenance"),
fuel: attachment("/fuel-entries"),
parts: attachment("/parts"),
},
getDocumentFileBlob: (id) => requestBlob(`/car-documents/${id}/file`),
deleteDocumentFile: (id) => request(`/car-documents/${id}/file`, { method: "DELETE" }),
// Reminders. The list mixes stored reminders with read-only ones derived from
// documents and service records (flagged `auto`; their ids start with "auto:").
@@ -0,0 +1,49 @@
<script setup>
// The attachment picker shared by every form that can carry a file.
//
// It only collects intent — a picked file, or a request to detach the existing
// one. Actually moving the bytes is the parent's job (applyAttachment), because
// the endpoint addresses a record that must already exist.
defineProps({
// The saved record, when editing; null while creating. Read for the name of
// whatever is already attached.
record: { type: Object, default: null },
file: { type: Object, default: null },
remove: { type: Boolean, default: false },
legend: { type: String, default: "Attachment" },
hint: { type: String, default: "PDF or image, up to 10MB." },
});
const emit = defineEmits(["update:file", "update:remove"]);
function onFilePick(e) {
const picked = e.target.files?.[0] || null;
emit("update:file", picked);
// Picking a replacement supersedes a pending detach.
if (picked) emit("update:remove", false);
}
</script>
<template>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">{{ legend }}</legend>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
class="w-full text-sm text-body"
@change="onFilePick"
/>
<p v-if="record?.hasFile && !file && !remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attached: <span class="data text-strong">{{ record.fileName }}</span></span>
<button type="button" class="font-medium text-danger hover:underline" @click="emit('update:remove', true)">
Remove
</button>
</p>
<p v-else-if="remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attachment will be removed on save.</span>
<button type="button" class="font-medium text-brandtext hover:underline" @click="emit('update:remove', false)">
Undo
</button>
</p>
<p class="mt-1.5 text-xs text-muted">{{ hint }}</p>
</fieldset>
</template>
@@ -1,6 +1,8 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -34,10 +36,7 @@ const form = ref({
notes: props.doc?.notes ?? "",
});
// The picked file is uploaded after the metadata save, since the attachment
// endpoint addresses a document that must already exist.
const file = ref(null);
// Tracks a request to detach the existing attachment without picking a new one.
const removeFile = ref(false);
function toDateInput(value) {
@@ -45,11 +44,6 @@ function toDateInput(value) {
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
}
function onFilePick(e) {
file.value = e.target.files?.[0] || null;
if (file.value) removeFile.value = false;
}
async function submit() {
saving.value = true;
error.value = "";
@@ -57,17 +51,10 @@ async function submit() {
const saved = isEdit
? await api.updateDocument(props.doc.id, payload())
: await api.createDocument(payload());
// Attachment changes are separate calls; a failure here must not read as a
// failed save, because the metadata is already committed.
let final = saved;
if (file.value) {
final = await api.uploadDocumentFile(saved.id, file.value);
} else if (removeFile.value && isEdit) {
await api.deleteDocumentFile(saved.id);
final = { ...saved, fileName: "", hasFile: false };
}
emit("saved", final);
emit("saved", await applyAttachment(api.files.documents, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
@@ -136,19 +123,7 @@ function payload() {
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Scan or photo</legend>
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,.heic" class="w-full text-sm text-body" @change="onFilePick" />
<p v-if="isEdit && doc.hasFile && !file && !removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attached: <span class="data text-strong">{{ doc.fileName }}</span></span>
<button type="button" class="font-medium text-danger hover:underline" @click="removeFile = true">Remove</button>
</p>
<p v-else-if="removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
<span>Attachment will be removed on save.</span>
<button type="button" class="font-medium text-brandtext hover:underline" @click="removeFile = false">Undo</button>
</p>
<p class="mt-1.5 text-xs text-muted">PDF or image, up to 10MB.</p>
</fieldset>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" legend="Scan or photo" />
<div>
<label class="dh-label">Notes</label>
+11 -1
View File
@@ -1,6 +1,8 @@
<script setup>
import { ref, computed } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -26,6 +28,9 @@ const form = ref({
notes: props.entry?.notes ?? "",
});
const file = ref(null);
const removeFile = ref(false);
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
@@ -43,7 +48,10 @@ async function submit() {
error.value = "";
try {
const saved = await (isEdit ? api.updateFuel(props.entry.id, payload()) : api.createFuel(payload()));
emit("saved", saved);
emit("saved", await applyAttachment(api.files.fuel, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
@@ -121,6 +129,8 @@ function payload() {
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Receipt" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
@@ -2,6 +2,8 @@
import { ref, computed } from "vue";
import { api } from "../api";
import { formatMoney } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -47,6 +49,9 @@ const form = ref({
notes: props.entry?.notes ?? "",
});
const file = ref(null);
const removeFile = ref(false);
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
@@ -64,7 +69,10 @@ async function submit() {
const saved = await (isEdit
? api.updateMaintenance(props.entry.id, payload())
: api.createMaintenance(payload()));
emit("saved", saved);
emit("saved", await applyAttachment(api.files.maintenance, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
@@ -169,6 +177,8 @@ function payload() {
</div>
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Invoice" />
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
+16 -1
View File
@@ -1,6 +1,8 @@
<script setup>
import { ref } from "vue";
import { api } from "../api";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -16,8 +18,12 @@ const form = ref({
name: props.part?.name ?? "",
partNumber: props.part?.partNumber ?? "",
category: props.part?.category ?? "",
notes: props.part?.notes ?? "",
});
const file = ref(null);
const removeFile = ref(false);
async function submit() {
saving.value = true;
error.value = "";
@@ -27,11 +33,15 @@ async function submit() {
name: form.value.name.trim(),
partNumber: form.value.partNumber.trim(),
category: form.value.category.trim(),
notes: form.value.notes.trim(),
};
const saved = isEdit
? await api.updatePart(props.part.id, payload)
: await api.createPart(payload);
emit("saved", saved);
emit("saved", await applyAttachment(api.files.parts, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
@@ -52,6 +62,11 @@ async function submit() {
<label class="dh-label">Part number</label>
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
</div>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" placeholder="Fits 20152020 · buy in pairs" class="dh-input" />
</div>
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" legend="Photo or spec sheet" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
@@ -2,6 +2,8 @@
import { ref } from "vue";
import { api } from "../api";
import { formatKm } from "../lib/format.js";
import { applyAttachment } from "../lib/attachment.js";
import AttachmentField from "./AttachmentField.vue";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -23,6 +25,9 @@ const form = ref({
notes: props.service?.notes ?? "",
});
const file = ref(null);
const removeFile = ref(false);
function toDateInput(value) {
const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
@@ -44,7 +49,10 @@ async function submit() {
const saved = isEdit
? await api.updateService(props.service.id, payload)
: await api.createService(payload);
emit("saved", saved);
emit("saved", await applyAttachment(api.files.services, saved, {
file: file.value,
remove: removeFile.value,
}));
} catch (e) {
error.value = e.message;
} finally {
@@ -69,10 +77,16 @@ async function submit() {
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">Changed parts</legend>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil &amp; oil filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil &amp; Oil filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
</fieldset>
<AttachmentField
v-model:file="file"
v-model:remove="removeFile"
:record="service"
legend="Receipt or service-book page"
/>
<div>
<label class="dh-label">Notes</label>
<input v-model="form.notes" class="dh-input" />
+16
View File
@@ -0,0 +1,16 @@
// Applies a form's pending attachment change to the record it has just saved,
// and returns the record the form should hand back to its parent.
//
// This necessarily runs after the metadata write: the file endpoints address a
// record that must already exist. The order means a create-with-file is two
// calls, and the second one failing leaves a saved record with no attachment —
// which is why the caller reports it as an attachment error rather than a failed
// save, because the metadata is already committed.
export async function applyAttachment(files, saved, { file, remove }) {
if (file) return files.upload(saved.id, file);
if (remove) {
await files.remove(saved.id);
return { ...saved, fileName: "", hasFile: false };
}
return saved;
}
+42 -11
View File
@@ -83,8 +83,8 @@ const TABS = [
{ key: "maintenance", label: "Maintenance log" },
{ key: "fuel", label: "Fuel" },
{ key: "documents", label: "Documents" },
{ key: "reminders", label: "Reminders" },
{ key: "parts", label: "Parts catalog" },
{ key: "reminders", label: "Reminders" },
];
async function load() {
@@ -258,15 +258,16 @@ async function deleteDocument(id) {
error.value = e.message;
}
}
// The attachment needs the auth header, so it is fetched as a Blob rather than
// linked to directly.
async function downloadDocument(doc) {
// --- attachments ---
// The file needs the auth header, so it is fetched as a Blob rather than linked
// to directly. `kind` names one of api.files — the same keys as the tabs.
async function downloadAttachment(kind, record) {
try {
const { blob, filename } = await api.getDocumentFileBlob(doc.id);
const { blob, filename } = await api.files[kind].download(record.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || doc.fileName || "document";
a.download = filename || record.fileName || "attachment";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
@@ -503,10 +504,11 @@ onMounted(load);
<th>Km</th>
<th>Next date</th>
<th>Next km</th>
<th class="!text-center">Oil &amp; filter</th>
<th class="!text-center">Engine air</th>
<th class="!text-center">Cabin air</th>
<th class="!text-center">Oil &amp; Oil filter</th>
<th class="!text-center">Engine air filter</th>
<th class="!text-center">Cabin air filter</th>
<th>Notes</th>
<th>File</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -520,6 +522,12 @@ onMounted(load);
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
@@ -558,6 +566,7 @@ onMounted(load);
<th>Workshop</th>
<th>Status</th>
<th class="!text-right">Cost</th>
<th>File</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -585,6 +594,12 @@ onMounted(load);
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="m.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('maintenance', m)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
@@ -667,6 +682,7 @@ onMounted(load);
<th class="!text-right">Distance</th>
<th class="!text-right">Consumption</th>
<th>Station</th>
<th>File</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -689,6 +705,12 @@ onMounted(load);
{{ f.station || '—' }}
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="f.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('fuel', f)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
@@ -744,7 +766,7 @@ onMounted(load);
<span :class="expiryStatus(d).classes">{{ expiryStatus(d).label }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadDocument(d)">
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('documents', d)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
@@ -827,12 +849,14 @@ onMounted(load);
No parts yet.
</div>
<div v-else class="dh-card overflow-hidden p-0">
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>Part</th>
<th>Part number</th>
<th>Notes</th>
<th>File</th>
<th v-if="canWrite"></th>
</tr>
</thead>
@@ -840,6 +864,13 @@ onMounted(load);
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
<td class="px-4 py-3 text-body">{{ p.notes || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3">
<button v-if="p.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('parts', p)">
Download
</button>
<span v-else class="text-xs text-muted"></span>
</td>
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>