- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
133 lines
5.6 KiB
Go
133 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// writeServiceError maps a service-layer sentinel error to pansy's JSON error
|
|
// envelope ({"error":{"code","message"}}). One mapper serves every handler
|
|
// (auth and resources) so the status/code for a given sentinel is defined once.
|
|
//
|
|
// ErrNotFound covers both a genuinely missing row and one the actor may not see
|
|
// (existence is masked). ErrVersionConflict here is a fallback that omits the
|
|
// current row — handlers that can produce one special-case it with
|
|
// writeVersionConflict before falling through here. Login failures never leak
|
|
// which of email/password was wrong.
|
|
func writeServiceError(c *gin.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, domain.ErrNotFound):
|
|
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "not found")
|
|
case errors.Is(err, domain.ErrForbidden):
|
|
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "you don't have access")
|
|
case errors.Is(err, domain.ErrVersionConflict):
|
|
writeAPIError(c, http.StatusConflict, "VERSION_CONFLICT", "the resource was modified; refetch and retry")
|
|
case errors.Is(err, domain.ErrPlantInUse):
|
|
writeAPIError(c, http.StatusConflict, "PLANT_IN_USE", "this plant is used by plantings and can't be deleted")
|
|
case errors.Is(err, domain.ErrShareUserNotFound):
|
|
writeAPIError(c, http.StatusNotFound, "SHARE_USER_NOT_FOUND", "no account with that email")
|
|
case errors.Is(err, domain.ErrCannotShareWithSelf):
|
|
writeAPIError(c, http.StatusBadRequest, "CANNOT_SHARE_WITH_SELF", "you can't share a garden with yourself")
|
|
case errors.Is(err, domain.ErrShareExists):
|
|
writeAPIError(c, http.StatusConflict, "SHARE_EXISTS", "this garden is already shared with that user")
|
|
case errors.Is(err, domain.ErrInvalidCredentials):
|
|
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password")
|
|
case errors.Is(err, domain.ErrEmailTaken):
|
|
writeAPIError(c, http.StatusConflict, "EMAIL_TAKEN", "an account with that email already exists")
|
|
case errors.Is(err, domain.ErrRegistrationClosed):
|
|
writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed")
|
|
case errors.Is(err, domain.ErrLocalAuthDisabled):
|
|
writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled")
|
|
case errors.Is(err, domain.ErrOIDCNoEmail):
|
|
writeAPIError(c, http.StatusBadRequest, "OIDC_NO_EMAIL", "the identity provider returned no email")
|
|
case errors.Is(err, domain.ErrOIDCEmailUnverified):
|
|
writeAPIError(c, http.StatusForbidden, "OIDC_EMAIL_UNVERIFIED", "the identity provider's email is not verified")
|
|
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
|
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
|
case errors.Is(err, domain.ErrInvalidInput):
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
|
|
default:
|
|
slog.Error("api: unhandled service error", "error", err)
|
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
|
}
|
|
}
|
|
|
|
// writeVersionConflict writes the 409 envelope for an optimistic-concurrency
|
|
// failure: the standard error object plus the current server row under
|
|
// "current", so the client can rebase its edit onto the fresh version and retry.
|
|
// This shape is the contract for every version-guarded (mutable) resource.
|
|
func writeVersionConflict(c *gin.Context, current any) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": gin.H{"code": "VERSION_CONFLICT", "message": "the resource was modified; refetch and retry"},
|
|
"current": current,
|
|
})
|
|
}
|
|
|
|
// parseNullable decodes any JSON value into (value, present) for a nullable
|
|
// column: absent → (nil, false); explicit null → (nil, true); anything else →
|
|
// the decoded value. That three-way distinction is what lets a PATCH tell "clear
|
|
// this to NULL" apart from "leave it alone", and every nullable field in the API
|
|
// needs it, so it lives here rather than in whichever file happened to want it
|
|
// first.
|
|
func parseNullable[T any](raw json.RawMessage) (*T, bool, error) {
|
|
if len(raw) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
if string(raw) == "null" {
|
|
return nil, true, nil
|
|
}
|
|
var v T
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return &v, true, nil
|
|
}
|
|
|
|
// intQuery reads a non-negative integer query parameter, falling back to def on
|
|
// an absent or malformed value.
|
|
func intQuery(c *gin.Context, name string, def int) int {
|
|
raw := c.Query(name)
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil || v < 0 {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
// parseIDParam reads a positive int64 path parameter, writing a 400 and
|
|
// returning ok=false on a malformed value.
|
|
func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
|
id, err := strconv.ParseInt(c.Param(name), 10, 64)
|
|
if err != nil || id < 1 {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid id")
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
|
|
// sentinel reads "invalid input"; a service that wraps it with a reason —
|
|
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
|
|
// — has that reason shown to the person verbatim, minus the sentinel prefix.
|
|
// So anything wrapped this way is written for the keyboard, not the log (see
|
|
// the note on domain.ErrInvalidInput).
|
|
func inputMessage(err error) string {
|
|
msg := err.Error()
|
|
base := domain.ErrInvalidInput.Error()
|
|
if msg == base {
|
|
return msg
|
|
}
|
|
return strings.TrimPrefix(msg, base+": ")
|
|
}
|