Files
DriverVault/API Server/internal/api/documents.go
T
tajniak81andClaude Opus 4.8 07192f1238 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>
2026-07-17 10:41:01 +02:00

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 ""
}