Files
DriverVault/API Server/internal/api/me.go
T
tajniak81andClaude Opus 5 f3235c403c A page opens where you put its tabs
Every tab bar in the app drags into the order you want, and then all three of
them opened on a tab picked in the source anyway: "public" on Charging, "info"
on a car, "personal" in Settings. Dragging Home chargers to the front of the
charging bar rearranged the bar and changed nothing about where the page landed,
which is the opposite of what dragging it there says.

So the front of the bar is now the landing tab, everywhere. An arrangement is
already the statement of what you want to see first; it just wasn't being read
as one. Settings > Appearance overrides it per page for the case where reading
order and landing tab are two different wishes, with "First in the bar" as the
default and the meaning of no override at all.

The rule lives in one place, lib/tabs.js, because it is one rule and three
pages: the saved choice if that tab is actually on the bar, otherwise whatever
leads it. The bar it is given is the one that will really render, hidden tabs
and inapplicable ones already dropped, so a default that no longer has a button
- a tab switched off for that car, Users on a non-admin - falls back to the
front instead of opening nothing. The tab key lists moved there too, since the
picker needs all three and would otherwise have copied them.

Each page starts on no tab and keeps following the profile until the user says
otherwise, rather than guessing and then correcting itself: the arrangement and
the default both arrive with /api/me, which on a hard refresh lands after the
view has mounted. A click ends the following, and so does the start of a drag -
rearranging a bar must not pull the content out from under the pointer. In
Settings ?tab= still wins over both, since that is what /admin redirects to.

Stored as defaultTabs on the profile, one page->tab map validated per page: a
tab that exists but on another page is an error, and an empty value is stored
as an absent key so "no default" has a single representation.

Also adds charger_tab_order and charger_card_order to the PocketBase setup
script. They were never there - the arrangements of the last two commits had no
column to persist into on a freshly set-up server - and default_tabs would have
gone the same way beside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:52:20 +02:00

760 lines
24 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"`
DragLocked bool `json:"drag_locked"`
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"`
// The charging page's tab arrangement, stored the same way and for the same
// reason.
ChargerTabOrder json.RawMessage `json:"charger_tab_order"`
ChargerCardOrder json.RawMessage `json:"charger_card_order"`
// Which tab each tabbed page opens on. Raw for the same reason as the
// arrangements: never-set records come back as null or "".
DefaultTabs json.RawMessage `json:"default_tabs"`
}
// 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) }
func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) }
func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) }
func (rec userRecord) defaultTabs() map[string]string { return decodeStringMap(rec.DefaultTabs) }
// 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
}
// decodeStringMap is decodeStringList for a json field holding a flat
// string->string map, and is just as forgiving about what it finds.
func decodeStringMap(raw json.RawMessage) map[string]string {
if len(raw) == 0 {
return nil
}
var out map[string]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"),
DragLocked: rec.DragLocked,
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
Organization: rec.Organization,
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
ChargerCardOrder: rec.chargerCardOrder(),
DefaultTabs: rec.defaultTabs(),
}
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"`
DragLocked *bool `json:"dragLocked"`
CarOrder *[]string `json:"carOrder"`
ChargerTabOrder *[]string `json:"chargerTabOrder"`
ChargerCardOrder *[]string `json:"chargerCardOrder"`
// The whole map every time — the clients send it back with one page changed,
// so a partial merge here would only make "no default" impossible to express.
DefaultTabs *map[string]string `json:"defaultTabs"`
}
// 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) {
return normalizeOrder("carOrder", in, maxCarOrder)
}
// maxChargerTabOrder bounds the charging page's tab arrangement. The page has
// two tabs; the room is for tabs a later release adds, not for a client to park
// a blob on the record.
const maxChargerTabOrder = 20
func normalizeChargerTabOrder(in []string) ([]string, error) {
return normalizeOrder("chargerTabOrder", in, maxChargerTabOrder)
}
func normalizeChargerCardOrder(in []string) ([]string, error) {
return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder)
}
// The tabbed pages a default tab can be set for, and the tabs each one has.
// Kept in step with the web app's src/lib/tabs.js. A key the page does not have
// would simply never match a button and the page would open on the front of its
// bar anyway — this is here to keep junk off the record, not to protect the
// clients from themselves.
var validDefaultTabs = map[string]map[string]bool{
"charging": {"public": true, "home": true},
"car": {
"provider": true, "info": true, "services": true, "technical": true,
"maintenance": true, "fuel": true, "charging": true, "documents": true,
"parts": true, "reminders": true,
},
"settings": {"personal": true, "users": true, "organization": true, "integrations": true},
}
// normalizeDefaultTabs cleans a client-supplied default-tab map. An empty value
// is how a client says "no default, open on the front of the bar", and is stored
// as an absent key rather than an empty string so the two cannot drift apart.
func normalizeDefaultTabs(in map[string]string) (map[string]string, error) {
out := make(map[string]string, len(in))
for page, tab := range in {
tabs, ok := validDefaultTabs[page]
if !ok {
return nil, fmt.Errorf("defaultTabs: %q is not a tabbed page", page)
}
tab = strings.TrimSpace(tab)
if tab == "" {
continue
}
if !tabs[tab] {
return nil, fmt.Errorf("defaultTabs: %q is not a tab on the %s page", tab, page)
}
out[page] = tab
}
return out, nil
}
func normalizeOrder(field string, in []string, max int) ([]string, error) {
if len(in) > max {
return nil, fmt.Errorf("%s is too long (max %d)", field, max)
}
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.DragLocked != nil {
// No value to validate — either the arrangements on this account's pages
// are held still or they are not.
payload["drag_locked"] = *in.DragLocked
}
if in.CarOrder != nil {
ids, err := normalizeCarOrder(*in.CarOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["car_order"] = ids
}
if in.ChargerTabOrder != nil {
keys, err := normalizeChargerTabOrder(*in.ChargerTabOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["charger_tab_order"] = keys
}
if in.ChargerCardOrder != nil {
keys, err := normalizeChargerCardOrder(*in.ChargerCardOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["charger_card_order"] = keys
}
if in.DefaultTabs != nil {
tabs, err := normalizeDefaultTabs(*in.DefaultTabs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["default_tabs"] = tabs
}
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)
}