Two things, both about layouts you arrange by dragging.
A car's tab bar now takes a drag: the tabs reorder as the pointer crosses
them and the arrangement saves on drop — or on dragend, since a tab
released in the gap beside the bar never produces a drop and would
otherwise revert on the next load. Same native drag events as the garage
and the Information rows, so also pointer-only, and it needs write access.
The order belongs to the car, like the choice of which tabs show at all,
so everyone it is shared with sees the same bar. It is stored as the full
list of keys, hidden tabs included, so a tab switched off and back on
returns to where it was rather than to the end; a key the stored
arrangement doesn't mention — a tab added in a later release — follows
the arranged ones. Information is arrangeable although it cannot be
switched off, which is why the validation needs arrangeableCarTabs rather
than reusing hideableCarTabs; it is derived from that set so the two
cannot drift as tabs are added. tabOrder rides on the existing PUT
/api/cars/{id}/view, so a tab drag never has to resend what is hidden.
Where the page opens is unchanged: Information, wherever it now sits.
And a padlock in the sidebar, above the theme toggle, holds every
arrangement in the app still at once — the garage, a car's tabs, its
Information rows, the provider's readings. It is a guard against nudging
a layout while reading it, not a permission: it is the user's own setting
and says nothing about what anybody may edit, so locking hides your own
drag handles rather than stopping a co-owner rearranging a shared car.
Stored as dragLocked on the profile, like the theme it sits above, so a
locked account is still locked on the next device — where a folded
provider card stays one browser's reading habit. Unlocked by default, so
nothing changes until it is clicked, and while locked the grab cursor and
the drag hints go with the drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
647 lines
20 KiB
Go
647 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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"),
|
|
DragLocked: rec.DragLocked,
|
|
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"`
|
|
DragLocked *bool `json:"dragLocked"`
|
|
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.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
|
|
}
|
|
|
|
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)
|
|
}
|