package api import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "carcontrol/api/internal/auth" "carcontrol/api/internal/models" ) // deletionCooldown is how long an account-deletion request sits before it can // be finalized, giving the user a window to change their mind. const deletionCooldown = 3 * 24 * time.Hour // userRecord is the PocketBase-facing shape of a user (mixes PocketBase's own // camelCase system fields with our snake_case custom ones). type userRecord struct { ID string `json:"id"` Email string `json:"email"` Verified bool `json:"verified"` Name string `json:"name"` Avatar string `json:"avatar"` Bio string `json:"bio"` Theme string `json:"theme"` Locale string `json:"locale"` DateFormat string `json:"date_format"` FontSize string `json:"font_size"` DeletionRequestedAt string `json:"deletion_requested_at"` Role string `json:"role"` Created string `json:"created"` } func (rec userRecord) toModel() models.User { u := models.User{ ID: rec.ID, Email: rec.Email, Verified: rec.Verified, Name: rec.Name, Bio: rec.Bio, HasAvatar: rec.Avatar != "", Theme: orDefault(rec.Theme, "system"), Locale: orDefault(rec.Locale, "en-US"), DateFormat: orDefault(rec.DateFormat, "YMD"), FontSize: orDefault(rec.FontSize, "medium"), Role: orDefault(rec.Role, "user"), Created: rec.Created, } if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() { u.DeletionRequestedAt = &t } return u } func orDefault(v, fallback string) string { if v == "" { return fallback } return v } func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) { var rec userRecord if err := s.pb.GetOne(r.Context(), s.usersCollection, id, &rec); err != nil { return nil, err } return &rec, nil } func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.Sub) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, rec.toModel()) } type updateMeRequest struct { Name *string `json:"name"` Bio *string `json:"bio"` Theme *string `json:"theme"` Locale *string `json:"locale"` DateFormat *string `json:"dateFormat"` FontSize *string `json:"fontSize"` } var validThemes = map[string]bool{"light": true, "dark": true, "system": true} var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true} var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true} // handleUpdateMe applies a partial update — only fields present in the request // body are touched, so the Account/Profile/Appearance sections of the settings // panel can each save independently without clobbering the others. func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } var in updateMeRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload := map[string]any{} if in.Name != nil { payload["name"] = *in.Name } if in.Bio != nil { payload["bio"] = *in.Bio } if in.Theme != nil { if !validThemes[*in.Theme] { writeError(w, http.StatusBadRequest, "theme must be light, dark, or system") return } payload["theme"] = *in.Theme } if in.Locale != nil { payload["locale"] = *in.Locale } if in.DateFormat != nil { if !validDateFormats[*in.DateFormat] { writeError(w, http.StatusBadRequest, "dateFormat must be YMD, DMY, or MDY") return } payload["date_format"] = *in.DateFormat } if in.FontSize != nil { if !validFontSizes[*in.FontSize] { writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large") return } payload["font_size"] = *in.FontSize } var rec userRecord if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, rec.toModel()) } type changePasswordRequest struct { OldPassword string `json:"oldPassword"` NewPassword string `json:"newPassword"` } func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } var in changePasswordRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.OldPassword == "" { writeError(w, http.StatusBadRequest, "current password is required") return } if len(in.NewPassword) < 8 { writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") return } // Verify the current password the same way login does, since the API // Server otherwise only ever talks to PocketBase as a superuser. if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, claims.Email, in.OldPassword); err != nil { writeError(w, http.StatusUnauthorized, "current password is incorrect") return } payload := map[string]any{"password": in.NewPassword, "passwordConfirm": in.NewPassword} if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } if err := r.ParseMultipartForm(5 << 20); err != nil { writeError(w, http.StatusBadRequest, "avatar upload must be under 5MB") return } file, header, err := r.FormFile("avatar") if err != nil { writeError(w, http.StatusBadRequest, "missing avatar file") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusInternalServerError, "could not read upload") return } if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil { writePBError(w, err) return } rec, err := s.fetchUser(r, claims.Sub) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, rec.toModel()) } func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, map[string]any{"avatar": ""}, nil); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.Sub) if err != nil { writePBError(w, err) return } if rec.Avatar == "" { writeError(w, http.StatusNotFound, "no avatar set") return } data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar) if err != nil { writePBError(w, err) return } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "private, max-age=300") w.Write(data) } func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "requested"}) } // handleExportData bundles the account profile plus every car the user owns // (with its service records and parts) into one downloadable JSON file. Cars // merely shared with the user are not exported — only cars they own. func (s *Server) handleExportData(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } user, err := s.fetchUser(r, claims.Sub) if err != nil { writePBError(w, err) return } carsRes, err := s.pb.List(r.Context(), colCars, url.Values{ "filter": {fmt.Sprintf("owner='%s'", claims.Sub)}, "sort": {"name"}, "perPage": {"200"}, }) if err != nil { writePBError(w, err) return } var carRecs []carRecord if err := json.Unmarshal(carsRes.Items, &carRecs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } type carExport struct { models.Car ServiceRecords []models.ServiceRecord `json:"serviceRecords"` Parts []models.Part `json:"parts"` } exportCars := make([]carExport, 0, len(carRecs)) for _, cr := range carRecs { car := cr.toModel() svcRes, err := s.pb.List(r.Context(), colServices, url.Values{ "filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"500"}, }) if err != nil { writePBError(w, err) return } var svcRecs []serviceRecord if err := json.Unmarshal(svcRes.Items, &svcRecs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } services := make([]models.ServiceRecord, 0, len(svcRecs)) for _, sr := range svcRecs { m := sr.toModel() m.ComputeDerived(&car) services = append(services, m) } partsRes, err := s.pb.List(r.Context(), colParts, url.Values{ "filter": {fmt.Sprintf("car='%s'", car.ID)}, "perPage": {"500"}, }) if err != nil { writePBError(w, err) return } var partRecs []partRecord if err := json.Unmarshal(partsRes.Items, &partRecs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } parts := make([]models.Part, 0, len(partRecs)) for _, p := range partRecs { parts = append(parts, p.toModel()) } exportCars = append(exportCars, carExport{Car: car, ServiceRecords: services, Parts: parts}) } out := map[string]any{ "exportedAt": time.Now().UTC().Format(time.RFC3339), "account": user.toModel(), "cars": exportCars, } filename := fmt.Sprintf("car-control-export-%s.json", time.Now().UTC().Format("2006-01-02")) w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) _ = json.NewEncoder(w).Encode(out) } // importCar mirrors handleExportData's per-car shape. The "id" and the // service records'/parts' "car" fields in the uploaded file are ignored — // this always creates brand-new records, remapped to the newly created car, // rather than trying to match or overwrite anything that already exists. type importCar struct { models.Car ServiceRecords []models.ServiceRecord `json:"serviceRecords"` Parts []models.Part `json:"parts"` } type importRequest struct { Cars []importCar `json:"cars"` } type importResult struct { CarsImported int `json:"carsImported"` ServicesImported int `json:"servicesImported"` PartsImported int `json:"partsImported"` } // handleImportData adds cars/service-records/parts from a previously exported // JSON file (or a hand-built one in the same shape). It only ever creates new // records — it does not merge with or overwrite existing shared household // data. Deliberately lenient about unknown top-level fields (e.g. the // export's "account"/"exportedAt"), since round-tripping the exact export // file is the main use case. func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } var in importRequest if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid import file: "+err.Error()) return } if len(in.Cars) == 0 { writeError(w, http.StatusBadRequest, "import file has no cars") return } var result importResult for _, ic := range in.Cars { car := ic.Car if car.Name == "" { continue // skip malformed entries rather than failing the whole import } applyCarDefaults(&car) // Imported cars are owned by the importing user, regardless of any // owner in the file. payload := carPayload(car) payload["owner"] = claims.Sub var rec carRecord if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil { writePBError(w, err) return } result.CarsImported++ for _, svc := range ic.ServiceRecords { svc.Car = rec.ID if err := s.pb.Create(r.Context(), colServices, servicePayload(svc), nil); err != nil { writePBError(w, err) return } result.ServicesImported++ } for _, p := range ic.Parts { p.Car = rec.ID if err := s.pb.Create(r.Context(), colParts, partPayload(p), nil); err != nil { writePBError(w, err) return } result.PartsImported++ } } writeJSON(w, http.StatusOK, result) } type deleteAccountRequest struct { ConfirmEmail string `json:"confirmEmail"` } // handleRequestDeletion starts the cooldown. The account is not touched yet — // handleFinalizeDeletion is a separate, later call once the cooldown elapses. func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } var in deleteAccountRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if !strings.EqualFold(strings.TrimSpace(in.ConfirmEmail), claims.Email) { writeError(w, http.StatusBadRequest, "typed email does not match your account email") return } now := time.Now().UTC() payload := map[string]any{"deletion_requested_at": formatPBDate(now)} if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "deletionRequestedAt": now.Format(time.RFC3339), "eligibleAt": now.Add(deletionCooldown).Format(time.RFC3339), }) } func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } payload := map[string]any{"deletion_requested_at": ""} if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) } // handleFinalizeDeletion actually deletes the account, but only once the // cooldown started by handleRequestDeletion has elapsed. Deleting the user // record cascades to their "sessions" rows (cascadeDelete relation); the // shared cars/service-records/parts data is untouched, since it belongs to // the household, not to one account. func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(claimsKey).(*auth.Claims) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.Sub) if err != nil { writePBError(w, err) return } requestedAt := parsePBDate(rec.DeletionRequestedAt) if requestedAt.IsZero() { writeError(w, http.StatusBadRequest, "no deletion request is pending") return } if time.Now().UTC().Before(requestedAt.Add(deletionCooldown)) { writeError(w, http.StatusForbidden, "the cooldown period has not elapsed yet") return } if err := s.pb.Delete(r.Context(), s.usersCollection, claims.Sub); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) }