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>
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
)
|
|
|
|
// Document tracking: insurance policies, pollution/emissions certificates,
|
|
// registration papers and the like, each with a renewal date.
|
|
//
|
|
// The expiry date is the reason the feature exists — a lapsed policy is a car
|
|
// that cannot legally be driven — so it is assessed live on every read
|
|
// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
|
|
// Documents also feed the auto-derived reminders in reminders.go.
|
|
//
|
|
// 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,
|
|
"pollution": true,
|
|
"registration": true,
|
|
"inspection": true,
|
|
"roadTax": true,
|
|
"warranty": true,
|
|
"other": true,
|
|
}
|
|
|
|
// listCarDocuments serves GET /api/cars/{id}/documents.
|
|
func (s *Server) listCarDocuments(w http.ResponseWriter, r *http.Request) {
|
|
carID := r.PathValue("id")
|
|
if !s.requireCarAccess(w, r, carID, accessRead) {
|
|
return
|
|
}
|
|
s.respondDocumentList(w, r, carID)
|
|
}
|
|
|
|
// listDocuments serves GET /api/car-documents?car={id}.
|
|
func (s *Server) listDocuments(w http.ResponseWriter, r *http.Request) {
|
|
carID := r.URL.Query().Get("car")
|
|
if !s.requireCarAccess(w, r, carID, accessRead) {
|
|
return
|
|
}
|
|
s.respondDocumentList(w, r, carID)
|
|
}
|
|
|
|
func (s *Server) respondDocumentList(w http.ResponseWriter, r *http.Request, carID string) {
|
|
docs, err := s.fetchDocuments(r, carID)
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
// fetchDocuments loads a car's documents with the soonest renewal first — the
|
|
// order in which they need attention.
|
|
func (s *Server) fetchDocuments(r *http.Request, carID string) ([]models.CarDocument, error) {
|
|
res, err := s.pb.List(r.Context(), colDocuments, url.Values{
|
|
"filter": {fmt.Sprintf("car='%s'", carID)},
|
|
"perPage": {"500"},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var recs []documentRecord
|
|
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now()
|
|
out := make([]models.CarDocument, 0, len(recs))
|
|
for _, rec := range recs {
|
|
d := rec.toModel()
|
|
d.ComputeExpiry(now)
|
|
out = append(out, d)
|
|
}
|
|
|
|
// Sorted here rather than by PocketBase: it orders a blank expiry_date ahead
|
|
// of every real date, which would file the documents that never expire above
|
|
// the ones that have already lapsed — the exact inverse of what this list is
|
|
// for. Everything with a renewal date comes first, soonest at the top.
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
ae, be := a.ExpiryDate != nil, b.ExpiryDate != nil
|
|
if ae != be {
|
|
return ae
|
|
}
|
|
if ae && be && !a.ExpiryDate.Equal(*b.ExpiryDate) {
|
|
return a.ExpiryDate.Before(*b.ExpiryDate)
|
|
}
|
|
return a.Title < b.Title
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) getDocument(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
|
|
}
|
|
d := rec.toModel()
|
|
d.ComputeExpiry(time.Now())
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (s *Server) createDocument(w http.ResponseWriter, r *http.Request) {
|
|
var in models.CarDocument
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if in.Type == "" {
|
|
in.Type = "other"
|
|
}
|
|
if msg := validateDocument(in); msg != "" {
|
|
writeError(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
|
return
|
|
}
|
|
var rec documentRecord
|
|
if err := s.pb.Create(r.Context(), colDocuments, documentPayload(in), &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
d := rec.toModel()
|
|
d.ComputeExpiry(time.Now())
|
|
writeJSON(w, http.StatusCreated, d)
|
|
}
|
|
|
|
func (s *Server) updateDocument(w http.ResponseWriter, r *http.Request) {
|
|
var in models.CarDocument
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
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
|
|
}
|
|
in.Car = existing.Car // the document's car is not reassignable via PATCH
|
|
if in.Type == "" {
|
|
in.Type = "other"
|
|
}
|
|
if msg := validateDocument(in); msg != "" {
|
|
writeError(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
var rec documentRecord
|
|
if err := s.pb.Update(r.Context(), colDocuments, r.PathValue("id"), documentPayload(in), &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
d := rec.toModel()
|
|
d.ComputeExpiry(time.Now())
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (s *Server) deleteDocument(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.Delete(r.Context(), colDocuments, r.PathValue("id")); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func validateDocument(d models.CarDocument) string {
|
|
switch {
|
|
case d.Car == "":
|
|
return "car is required"
|
|
case strings.TrimSpace(d.Title) == "":
|
|
return "title is required"
|
|
case !documentTypes[d.Type]:
|
|
return "type must be one of: insurance, pollution, registration, inspection, roadTax, warranty, other"
|
|
case d.Cost < 0:
|
|
return "cost cannot be negative"
|
|
}
|
|
if d.IssueDate != nil && d.ExpiryDate != nil && d.ExpiryDate.Before(*d.IssueDate) {
|
|
return "expiry date cannot be before the issue date"
|
|
}
|
|
return ""
|
|
}
|