diff --git a/API Server/internal/api/attachments.go b/API Server/internal/api/attachments.go new file mode 100644 index 0000000..348ac9e --- /dev/null +++ b/API Server/internal/api/attachments.go @@ -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 "" +} diff --git a/API Server/internal/api/documents.go b/API Server/internal/api/documents.go index 7d10050..98282ee 100644 --- a/API Server/internal/api/documents.go +++ b/API Server/internal/api/documents.go @@ -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 "" -} diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index 641ea20..e3b9e55 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -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, diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 72b27fe..6a19e95 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) } diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index be84b36..660b605 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 77dfb32..14b7cf4 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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 diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index a9c6c17..5acfa63 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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 +// . 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:"). diff --git a/Web App/web/src/components/AttachmentField.vue b/Web App/web/src/components/AttachmentField.vue new file mode 100644 index 0000000..2e7ee7b --- /dev/null +++ b/Web App/web/src/components/AttachmentField.vue @@ -0,0 +1,49 @@ + + + diff --git a/Web App/web/src/components/DocumentFormModal.vue b/Web App/web/src/components/DocumentFormModal.vue index d726ce9..3468622 100644 --- a/Web App/web/src/components/DocumentFormModal.vue +++ b/Web App/web/src/components/DocumentFormModal.vue @@ -1,6 +1,8 @@