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:
co-authored by
Claude Opus 4.8
parent
e64c89a564
commit
07192f1238
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user