Organization writes were superadmin-only, so standing up a tenant needed an out-of-band superadmin. Creating one is now self-service, and an admin manages the org they belong to. - POST /api/orgs is open to any authenticated user. A creator who isn't a superadmin must have no organization yet (a single-valued membership relation means a second one would abandon the first), and is promoted to the new org's admin and first member in the same request. If that promotion fails the org is rolled back, so it is never left stranded with nobody able to administer it. Superadmins still create tenants without joining them. - PATCH/DELETE are manager-gated and scope an admin to their own org. An admin deletes theirs only as its sole member: they are detached and demoted to a plain user before the record goes, so the org is empty when it is removed. Other members still block deletion with a 409. - /api/me now carries organization + organizationName, which the clients need to tell "no org yet" from "org you administer". The panel, Web App (new OrgManager.vue in Settings) and Phone App (new _OrganizationSection) all mirror the server's gates rather than re-deciding them. The Phone App cached its role at login and gates the Users tab on it, so AuthService.adoptRole refreshes that from the profile instead of making a freshly promoted admin sign in again. Covered by orgs_test.go, which drives the real handler + middleware chain against a stand-in PocketBase: promotion, the already-a-member refusal, superadmin staying unattached, the rollback, own-org scoping, the detach-and-demote, and the blocking-member 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
580 lines
18 KiB
Go
580 lines
18 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"`
|
|
}
|
|
|
|
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,
|
|
}
|
|
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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|