Files
tajniak81andClaude Opus 5 0f48093d1a A week that starts where the person reading it starts theirs
The scheduler's day picker began on Sunday because that is where Intl
numbers the days from, which is a fact about the API and not about
anybody's week. Monday leads it across most of Europe. A row of seven
buttons in the wrong order is not just odd to read — it is easy to
misclick, and a misclicked day in a schedule is a car charging on the
wrong night.

So Settings › Appearance asks, beneath the date and the clock, as the
third question a region gets: first day of the week, following the region
unless it is answered outright. The same shape the time format already
had, and the same "auto" default, so nothing changes for an account that
never opens it.

The rule lives in lib/format.js beside the clock's, with the ordering, the
day names and the sort all coming from there. The two places that lay
weekdays out — the picker and the line each task is summarised on — read
it rather than each keeping an opinion, so a day set is written and read
back in the same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:12:10 +02:00

791 lines
25 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"`
TimeFormat string `json:"time_format"`
WeekStart string `json:"week_start"`
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"),
TimeFormat: orDefault(rec.TimeFormat, "auto"),
WeekStart: orDefault(rec.WeekStart, "auto"),
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"`
TimeFormat *string `json:"timeFormat"`
WeekStart *string `json:"weekStart"`
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}
// "auto" reads the clock the way the chosen region writes it, which is what
// the app did before there was a setting; the other two say it outright, for
// the people whose region and habit disagree.
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
// Which day a week is drawn as starting on. "auto" is the chosen region's own
// convention — Monday across most of Europe, Sunday in the US — and is what
// every weekday row read before there was a setting; the other two say it
// outright, for the people whose region and habit disagree.
var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": 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.TimeFormat != nil {
if !validTimeFormats[*in.TimeFormat] {
writeError(w, http.StatusBadRequest, "timeFormat must be auto, 24, or 12")
return
}
payload["time_format"] = *in.TimeFormat
}
if in.WeekStart != nil {
if !validWeekStarts[*in.WeekStart] {
writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday")
return
}
payload["week_start"] = *in.WeekStart
}
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)
}