package api import ( "errors" "log/slog" "net/http" "strconv" "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.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", "invalid input") 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, }) } // 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 }