Three things you can now set up rather than live with.
The garage takes a drag: cards reorder as you drag across them and the
arrangement saves on drop — or on dragend, since a card released in the
gap between cards never produces a drop and would otherwise revert on
the next load. It is a per-user list of car ids on the profile, so it
covers cars shared with you and never reorders anybody else's garage;
the API returns /api/cars in that order, so a client only sends the new
one back. Pointer-only: touch browsers don't fire the native drag
events, and this is not worth a dependency.
A car's page is now configurable from the gear in its header: which tabs
it shows, and which of the 14 Information rows. Both belong to the car,
so everyone it is shared with sees the same page — Fuel off on an EV
stays off for all of them — and setting them needs write access. Stored
as the hidden sets, so anything added in a later release is on by
default. PUT /api/cars/{id}/view is its own endpoint precisely so an
ordinary save of the car form, which sends every other field, can never
reveal something that was deliberately switched off. Information itself
can't be hidden: a page with no tabs left would be a dead end.
The connected-service cards fold away, remembered per device, so a
provider that reports eight sections can be trimmed to the two worth
watching. A failed section keeps a short badge in its collapsed header
and puts the provider's own message — a few hundred characters of JSON,
which used to stretch the page sideways — inside the body with
everything else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
639 lines
20 KiB
Go
639 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/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"`
|
|
Currency string `json:"currency"`
|
|
FontSize string `json:"font_size"`
|
|
DeletionRequestedAt string `json:"deletion_requested_at"`
|
|
Role string `json:"role"`
|
|
Organization string `json:"organization"`
|
|
Created string `json:"created"`
|
|
|
|
// Garage arrangement. Raw because PocketBase hands back whatever a json field
|
|
// holds — null on a record that has never been arranged, and "" on one
|
|
// PocketBase stored as an empty value — neither of which is a []string.
|
|
CarOrder json.RawMessage `json:"car_order"`
|
|
}
|
|
|
|
// carOrder decodes the stored garage arrangement, treating anything unexpected
|
|
// as "not arranged yet" rather than failing the whole profile read.
|
|
func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder) }
|
|
|
|
// decodeStringList reads a PocketBase json field that holds a list of strings,
|
|
// treating anything unexpected as empty rather than failing the whole read.
|
|
func decodeStringList(raw json.RawMessage) []string {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
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"),
|
|
Currency: orDefault(rec.Currency, "USD"),
|
|
FontSize: orDefault(rec.FontSize, "medium"),
|
|
Role: orDefault(rec.Role, "user"),
|
|
Created: rec.Created,
|
|
|
|
Organization: rec.Organization,
|
|
CarOrder: rec.carOrder(),
|
|
}
|
|
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
|
|
u.DeletionRequestedAt = &t
|
|
}
|
|
return u
|
|
}
|
|
|
|
func orDefault(v, fallback string) string {
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
// profileOf builds the profile payload for a user record, resolving their
|
|
// organization's name so the clients can label the membership without a second
|
|
// round trip. The lookup is best effort — an unresolvable name leaves the id.
|
|
func (s *Server) profileOf(r *http.Request, rec *userRecord) models.User {
|
|
u := rec.toModel()
|
|
if u.Organization != "" {
|
|
u.OrganizationName = s.orgName(r.Context(), u.Organization)
|
|
}
|
|
return u
|
|
}
|
|
|
|
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 := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
rec, err := s.fetchUser(r, claims.ID)
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.profileOf(r, rec))
|
|
}
|
|
|
|
type updateMeRequest struct {
|
|
Name *string `json:"name"`
|
|
Bio *string `json:"bio"`
|
|
Theme *string `json:"theme"`
|
|
Locale *string `json:"locale"`
|
|
DateFormat *string `json:"dateFormat"`
|
|
Currency *string `json:"currency"`
|
|
FontSize *string `json:"fontSize"`
|
|
CarOrder *[]string `json:"carOrder"`
|
|
}
|
|
|
|
// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
|
|
// plus 200 shared cars, so this leaves room without letting a client park an
|
|
// unbounded blob on the record.
|
|
const maxCarOrder = 500
|
|
|
|
// normalizeCarOrder cleans a client-supplied garage arrangement: blanks out,
|
|
// duplicates dropped (first position wins), length capped. The ids are not
|
|
// checked against real cars — that would cost a lookup per entry, and an id for
|
|
// a car the user no longer has is harmless: listCars ignores what it can't
|
|
// match, and the next drag rewrites the list anyway.
|
|
func normalizeCarOrder(in []string) ([]string, error) {
|
|
if len(in) > maxCarOrder {
|
|
return nil, fmt.Errorf("carOrder is too long (max %d)", maxCarOrder)
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
seen := make(map[string]bool, len(in))
|
|
for _, id := range in {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
out = append(out, id)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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}
|
|
|
|
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
|
|
// PocketBase rejects anything outside its own list, so accepting a wider set
|
|
// here would only turn a clear 400 into a confusing upstream error.
|
|
var validCurrencies = map[string]bool{
|
|
"EUR": true, "GBP": true, "CHF": true, "PLN": true, "CZK": true, "HUF": true,
|
|
"RON": true, "BGN": true, "DKK": true, "SEK": true, "NOK": true, "ISK": true,
|
|
"ALL": true, "AMD": true, "AZN": true, "BAM": true, "BYN": true, "GEL": true,
|
|
"MDL": true, "MKD": true, "RSD": true, "RUB": true, "TRY": true, "UAH": true,
|
|
"USD": true, "CAD": true, "AUD": true, "JPY": true,
|
|
}
|
|
|
|
// The clients pick language and region separately and join them into this tag,
|
|
// so the stored value is only ever language-REGION. Enforcing that shape here
|
|
// keeps a bad tag out of the record: the web app feeds the locale straight to
|
|
// Intl, which throws on a malformed one rather than falling back.
|
|
var localePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`)
|
|
|
|
// 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 := caller(r)
|
|
if claims == nil {
|
|
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 {
|
|
if !localePattern.MatchString(*in.Locale) {
|
|
writeError(w, http.StatusBadRequest, "locale must look like en-US")
|
|
return
|
|
}
|
|
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.Currency != nil {
|
|
if !validCurrencies[*in.Currency] {
|
|
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
|
|
return
|
|
}
|
|
payload["currency"] = *in.Currency
|
|
}
|
|
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
|
|
}
|
|
if in.CarOrder != nil {
|
|
ids, err := normalizeCarOrder(*in.CarOrder)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["car_order"] = ids
|
|
}
|
|
|
|
var rec userRecord
|
|
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.profileOf(r, &rec))
|
|
}
|
|
|
|
type changePasswordRequest struct {
|
|
OldPassword string `json:"oldPassword"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
claims := caller(r)
|
|
if claims == nil {
|
|
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.ID, 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 := caller(r)
|
|
if claims == nil {
|
|
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.ID, nil, "avatar", header.Filename, data); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
rec, err := s.fetchUser(r, claims.ID)
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.profileOf(r, rec))
|
|
}
|
|
|
|
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
|
claims := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, 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 := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
rec, err := s.fetchUser(r, claims.ID)
|
|
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 := caller(r)
|
|
if claims == nil {
|
|
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 := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
user, err := s.fetchUser(r, claims.ID)
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
|
|
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
|
|
"filter": {fmt.Sprintf("owner='%s'", claims.ID)},
|
|
"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 := caller(r)
|
|
if claims == nil {
|
|
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.ID
|
|
|
|
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 := caller(r)
|
|
if claims == nil {
|
|
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.ID, 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 := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
payload := map[string]any{"deletion_requested_at": ""}
|
|
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, 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 := caller(r)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
rec, err := s.fetchUser(r, claims.ID)
|
|
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.ID); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|