@@ -0,0 +1,250 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type accountSession struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
func (app *application) listAccountSessionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
currentID := app.sessions.GetString(r.Context(), accountSessionIDKey)
|
||||
sessions := make([]accountSession, 0)
|
||||
err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error {
|
||||
if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) != user.ID {
|
||||
return nil
|
||||
}
|
||||
id := app.sessions.GetString(ctx, accountSessionIDKey)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
sessions = append(sessions, accountSession{
|
||||
ID: id,
|
||||
CreatedAt: time.Unix(app.sessions.GetInt64(ctx, accountSessionCreatedAtKey), 0).UTC(),
|
||||
ExpiresAt: app.sessions.Deadline(ctx),
|
||||
Current: id == currentID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"sessions": sessions}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteAccountSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
targetID := httprouter.ParamsFromContext(r.Context()).ByName("sessionID")
|
||||
if targetID == app.sessions.GetString(r.Context(), accountSessionIDKey) {
|
||||
if err := app.sessions.Destroy(r.Context()); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
found := false
|
||||
err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error {
|
||||
if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) == user.ID && app.sessions.GetString(ctx, accountSessionIDKey) == targetID {
|
||||
found = true
|
||||
return app.sessions.Destroy(ctx)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) updateAccountProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
user.Name = strings.TrimSpace(input.Name)
|
||||
if color := strings.TrimSpace(input.Color); color != "" {
|
||||
user.Color = color
|
||||
}
|
||||
v := validate.New()
|
||||
storage.ValidateUser(v, user)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
updated, err := app.models.Users.Update(user)
|
||||
if err != nil {
|
||||
app.respondToAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateAccountPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
v := validate.New()
|
||||
auth.ValidatePasswordPlaintext(v, input.CurrentPassword)
|
||||
auth.ValidatePasswordPlaintext(v, input.NewPassword)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
match, err := user.Password.Matches(input.CurrentPassword)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !match {
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = user.Password.Set(input.NewPassword); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err = app.models.Users.Update(user); err != nil {
|
||||
app.respondToAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"message": "password updated"}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) requestAccountEmailChangeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
CurrentPassword string `json:"current_password"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
v := validate.New()
|
||||
storage.ValidateEmail(v, input.Email)
|
||||
auth.ValidatePasswordPlaintext(v, input.CurrentPassword)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
match, err := user.Password.Matches(input.CurrentPassword)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !match {
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
return
|
||||
}
|
||||
if existing, lookupErr := app.models.Users.GetByEmail(input.Email); lookupErr == nil && existing.ID != user.ID {
|
||||
v.AddError("email", "email address is already registered")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
} else if lookupErr != nil && !errors.Is(lookupErr, storage.ErrRecordNotFound) {
|
||||
app.serverErrorResponse(w, r, lookupErr)
|
||||
return
|
||||
}
|
||||
token, err := app.models.Users.CreateEmailChange(user.ID, input.Email, 45*time.Minute)
|
||||
if err != nil {
|
||||
app.respondToAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
app.background(func() {
|
||||
data := map[string]any{"confirmationURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/account/email-confirm?token=" + token}
|
||||
if sendErr := app.mailer.Send(input.Email, "email_change.tmpl", data); sendErr != nil {
|
||||
app.logger.Error(sendErr.Error())
|
||||
}
|
||||
})
|
||||
if err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "confirmation email sent"}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) confirmAccountEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
v := validate.New()
|
||||
auth.ValidateTokenPlaintext(v, input.Token)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
updated, err := app.models.Users.ConfirmEmailChange(input.Token, user.ID)
|
||||
if err != nil {
|
||||
app.respondToAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) respondToAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrDuplicateEmail):
|
||||
app.failedValidationResponse(w, r, map[string]string{"email": "email address is already registered"})
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
case errors.Is(err, storage.ErrEditConflict):
|
||||
app.editConflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
const adminInvitationTTL = 3 * 24 * time.Hour
|
||||
|
||||
func (app *application) listAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := app.models.Users.GetAll()
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"users": users}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) inviteAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
candidate := storage.User{Name: input.Name, Email: input.Email, Activated: false}
|
||||
if passwordErr := candidate.Password.Set(rand.Text()); passwordErr != nil {
|
||||
app.serverErrorResponse(w, r, passwordErr)
|
||||
return
|
||||
}
|
||||
v := validate.New()
|
||||
if storage.ValidateUser(v, candidate); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
switch {
|
||||
case err == nil && user.Activated:
|
||||
app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"})
|
||||
return
|
||||
case err == nil:
|
||||
// Re-sending for an inactive account lets an administrator recover from
|
||||
// an earlier delivery failure without creating a duplicate account.
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
user, err = app.models.Users.Insert(candidate)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateEmail) {
|
||||
app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"})
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
token, err := app.models.Tokens.New(user.ID, adminInvitationTTL, auth.ScopeActivation)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
activationURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/activate?token=" + url.QueryEscape(token.Plaintext) + "&set-password=1"
|
||||
if err = app.mailer.Send(user.Email, "user_invitation.tmpl", map[string]any{"name": user.Name, "activationURL": activationURL}); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateAdminUserRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
actor, _ := app.contextGetAuthenticatedUser(r)
|
||||
if actor.ID == userID {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Role storage.ApplicationRole `json:"role"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
role, err := app.models.Roles.Get(string(input.Role))
|
||||
if err != nil || role.Scope != storage.RoleScopeApplication {
|
||||
app.failedValidationResponse(w, r, map[string]string{"role": "must be an application role"})
|
||||
return
|
||||
}
|
||||
user, err := app.models.Users.UpdateRole(userID, input.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
app.notFoundResponse(w, r)
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
actor, _ := app.contextGetAuthenticatedUser(r)
|
||||
if actor.ID == userID {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.Users.Delete(userID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
case errors.Is(err, storage.ErrConflict):
|
||||
app.conflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type environmentVariable struct {
|
||||
Component string `json:"component"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
variables := []environmentVariable{
|
||||
{Component: "API", Name: "GARDOMATIC_ENV", Value: app.config.Env},
|
||||
{Component: "API", Name: "GARDOMATIC_DB_DSN", Value: maskedValue(app.config.DB.Dsn)},
|
||||
{Component: "API", Name: "GARDOMATIC_DB_MAX_OPEN_CONNS", Value: fmt.Sprint(app.config.DB.MaxOpenConns)},
|
||||
{Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_CONNS", Value: fmt.Sprint(app.config.DB.MaxIdleConns)},
|
||||
{Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_TIME", Value: app.config.DB.MaxIdleTime.String()},
|
||||
{Component: "API", Name: "GARDOMATIC_API_HOST", Value: app.config.Host},
|
||||
{Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)},
|
||||
{Component: "API", Name: "GARDOMATIC_WEB_BASE_URL", Value: app.config.WebBaseURL},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.Session.CookieName},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()},
|
||||
{Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()},
|
||||
{Component: "API", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.Session.CookieSecure)},
|
||||
{Component: "API", Name: "GARDOMATIC_RATE_LIMIT_ENABLED", Value: fmt.Sprint(app.config.Limiter.Enabled)},
|
||||
{Component: "API", Name: "GARDOMATIC_RATE_LIMIT_RPS", Value: fmt.Sprint(app.config.Limiter.Rps)},
|
||||
{Component: "API", Name: "GARDOMATIC_RATE_LIMIT_BURST", Value: fmt.Sprint(app.config.Limiter.Burst)},
|
||||
{Component: "API", Name: "GARDOMATIC_CORS_TRUSTED_ORIGINS", Value: strings.Join(app.config.Cors.TrustedOrigins, ",")},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_MODE", Value: string(app.config.Mail.Mode)},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_HOST", Value: app.config.Mail.Host},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_PORT", Value: fmt.Sprint(app.config.Mail.Port)},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_USERNAME", Value: app.config.Mail.Username},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_PASSWORD", Value: maskedValue(app.config.Mail.Password)},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_SENDER", Value: app.config.Mail.Sender},
|
||||
{Component: "API", Name: "GARDOMATIC_SMTP_FILE_PATH", Value: app.config.Mail.FilePath},
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"variables": variables}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func maskedValue(value string) string {
|
||||
if value == "" {
|
||||
return "(nicht gesetzt)"
|
||||
}
|
||||
return "•••••••• (gesetzt)"
|
||||
}
|
||||
|
||||
func (app *application) sendAdminTestMailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
v := validate.New()
|
||||
storage.ValidateEmail(v, input.Email)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
if err := app.mailer.Send(input.Email, "test_mail.tmpl", nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusAccepted, envelope{"message": "test mail sent"}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type adminInviteUserModel struct {
|
||||
sessionTestUserModel
|
||||
stored storage.User
|
||||
deletedID int
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (m *adminInviteUserModel) Insert(user storage.User) (storage.User, error) {
|
||||
user.ID = 42
|
||||
m.stored = user
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *adminInviteUserModel) GetByEmail(email string) (storage.User, error) {
|
||||
if m.stored.ID == 0 || m.stored.Email != email {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return m.stored, nil
|
||||
}
|
||||
|
||||
func (m *adminInviteUserModel) GetForToken(scope, token string) (storage.User, error) {
|
||||
if scope != auth.ScopeActivation || token == "" || m.stored.ID == 0 {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return m.stored, nil
|
||||
}
|
||||
|
||||
func (m *adminInviteUserModel) Update(user storage.User) (storage.User, error) {
|
||||
m.stored = user
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *adminInviteUserModel) Delete(userID int) error {
|
||||
m.deletedID = userID
|
||||
return m.deleteErr
|
||||
}
|
||||
|
||||
type adminInviteTokenModel struct {
|
||||
token auth.Token
|
||||
deleted bool
|
||||
}
|
||||
|
||||
func (m *adminInviteTokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) {
|
||||
m.token = auth.NewToken(userID, ttl, scope)
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *adminInviteTokenModel) DeleteAllForUser(scope string, userID int) error {
|
||||
m.deleted = scope == auth.ScopeActivation && userID == 42
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAdminInvitationCreatesAccountAndAllowsInitialPassword(t *testing.T) {
|
||||
users := new(adminInviteUserModel)
|
||||
tokens := new(adminInviteTokenModel)
|
||||
mailPath := filepath.Join(t.TempDir(), "mail.log")
|
||||
configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, _, _ := newGardenTestApplication()
|
||||
app.config.WebBaseURL = "https://garden.example.com"
|
||||
app.models.Users = users
|
||||
app.models.Tokens = tokens
|
||||
app.mailer = configuredMailer
|
||||
|
||||
inviteResponse := httptest.NewRecorder()
|
||||
inviteRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":" Ada ","email":" ADA@EXAMPLE.COM "}`))
|
||||
app.inviteAdminUserHandler(inviteResponse, inviteRequest)
|
||||
if inviteResponse.Code != http.StatusAccepted {
|
||||
t.Fatalf("invite status: got %d, want %d; body: %s", inviteResponse.Code, http.StatusAccepted, inviteResponse.Body.String())
|
||||
}
|
||||
if users.stored.Name != "Ada" || users.stored.Email != "ada@example.com" || users.stored.Activated {
|
||||
t.Fatalf("unexpected invited user: %+v", users.stored)
|
||||
}
|
||||
if !tokens.deleted || tokens.token.UserID != users.stored.ID || tokens.token.Scope != auth.ScopeActivation {
|
||||
t.Fatalf("unexpected invitation token: %+v", tokens.token)
|
||||
}
|
||||
firstToken := tokens.token.Plaintext
|
||||
resendResponse := httptest.NewRecorder()
|
||||
resendRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`))
|
||||
app.inviteAdminUserHandler(resendResponse, resendRequest)
|
||||
if resendResponse.Code != http.StatusAccepted {
|
||||
t.Fatalf("resend status: got %d, want %d; body: %s", resendResponse.Code, http.StatusAccepted, resendResponse.Body.String())
|
||||
}
|
||||
if tokens.token.Plaintext == firstToken {
|
||||
t.Fatal("resending did not replace the activation token")
|
||||
}
|
||||
mailContent, err := os.ReadFile(mailPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"ada@example.com", "Einladung zu Gardomatic", "set-password=3D1", tokens.token.Plaintext} {
|
||||
if !strings.Contains(string(mailContent), want) {
|
||||
t.Errorf("invitation is missing %q: %s", want, mailContent)
|
||||
}
|
||||
}
|
||||
|
||||
activateResponse := httptest.NewRecorder()
|
||||
activateBody := `{"token":"` + tokens.token.Plaintext + `","password":"correct horse battery staple"}`
|
||||
activateRequest := httptest.NewRequest(http.MethodPut, "/v1/users/activated", strings.NewReader(activateBody))
|
||||
app.activateUserHandler(activateResponse, activateRequest)
|
||||
if activateResponse.Code != http.StatusOK {
|
||||
t.Fatalf("activation status: got %d, want %d; body: %s", activateResponse.Code, http.StatusOK, activateResponse.Body.String())
|
||||
}
|
||||
if !users.stored.Activated {
|
||||
t.Fatal("invited user was not activated")
|
||||
}
|
||||
matches, err := users.stored.Password.Matches("correct horse battery staple")
|
||||
if err != nil || !matches {
|
||||
t.Fatalf("initial password was not stored: matches=%t err=%v", matches, err)
|
||||
}
|
||||
|
||||
duplicateResponse := httptest.NewRecorder()
|
||||
duplicateRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`))
|
||||
app.inviteAdminUserHandler(duplicateResponse, duplicateRequest)
|
||||
if duplicateResponse.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("active duplicate status: got %d, want %d; body: %s", duplicateResponse.Code, http.StatusUnprocessableEntity, duplicateResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAdminUserProtectsSelfAndReportsOwnerConflict(t *testing.T) {
|
||||
users := new(adminInviteUserModel)
|
||||
app, _, _ := newGardenTestApplication()
|
||||
app.models.Users = users
|
||||
router := httprouter.New()
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.deleteAdminUserHandler)
|
||||
|
||||
serve := func(actorID, targetID int) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodDelete, "/v1/admin/users/"+strconv.Itoa(targetID), nil)
|
||||
request = app.contextSetAuthenticatedUser(request, storage.User{ID: actorID})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
if response := serve(7, 7); response.Code != http.StatusForbidden || users.deletedID != 0 {
|
||||
t.Fatalf("self deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
||||
}
|
||||
users.deleteErr = storage.ErrConflict
|
||||
if response := serve(7, 8); response.Code != http.StatusConflict || users.deletedID != 8 {
|
||||
t.Fatalf("owner deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
||||
}
|
||||
users.deleteErr = nil
|
||||
if response := serve(7, 9); response.Code != http.StatusNoContent || users.deletedID != 9 {
|
||||
t.Fatalf("deletion: status=%d deleted=%d", response.Code, users.deletedID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"gardomatic.kleiax.de/internal/storage/postgres"
|
||||
"gardomatic.kleiax.de/internal/vcs"
|
||||
|
||||
"github.com/alexedwards/scs/postgresstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
version = vcs.Version()
|
||||
)
|
||||
|
||||
// Config contains API server, database, session, mail, and security settings.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Env string
|
||||
WebBaseURL string
|
||||
DB DatabaseConfig
|
||||
Limiter LimiterConfig
|
||||
Session SessionConfig
|
||||
Mail mailer.Config
|
||||
Cors CORSConfig
|
||||
}
|
||||
|
||||
// DatabaseConfig controls the PostgreSQL connection pool.
|
||||
type DatabaseConfig struct {
|
||||
Dsn string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
MaxIdleTime time.Duration
|
||||
}
|
||||
|
||||
// LimiterConfig controls per-client HTTP rate limiting.
|
||||
type LimiterConfig struct {
|
||||
Enabled bool
|
||||
Rps float64
|
||||
Burst int
|
||||
}
|
||||
|
||||
// SessionConfig controls the session cookie and server-side session lifetime.
|
||||
type SessionConfig struct {
|
||||
Lifetime time.Duration
|
||||
IdleTimeout time.Duration
|
||||
CookieName string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
// CORSConfig lists origins allowed to make cross-origin API requests.
|
||||
type CORSConfig struct {
|
||||
TrustedOrigins []string
|
||||
}
|
||||
|
||||
type application struct {
|
||||
config Config
|
||||
logger *slog.Logger
|
||||
models storage.Models
|
||||
mailer *mailer.Mailer
|
||||
sessions *scs.SessionManager
|
||||
wg sync.WaitGroup
|
||||
// db is retained by the application so Run can close the connection pool.
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New initializes the API application and its database-backed dependencies.
|
||||
func New(cfg Config) *application {
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil)
|
||||
if cfg.Env == "production" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, nil)
|
||||
}
|
||||
logger := slog.New(handler)
|
||||
|
||||
db, err := openDB(cfg)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("database connection pool established")
|
||||
|
||||
expvar.NewString("version").Set(version)
|
||||
|
||||
expvar.Publish("goroutines", expvar.Func(func() any {
|
||||
return runtime.NumGoroutine()
|
||||
}))
|
||||
|
||||
expvar.Publish("database", expvar.Func(func() any {
|
||||
return db.Stats()
|
||||
}))
|
||||
|
||||
expvar.Publish("timestamp", expvar.Func(func() any {
|
||||
return time.Now().Unix()
|
||||
}))
|
||||
|
||||
mailer, err := mailer.New(cfg.Mail)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sessions := scs.New()
|
||||
sessions.Store = postgresstore.New(db)
|
||||
sessions.Lifetime = cfg.Session.Lifetime
|
||||
sessions.IdleTimeout = cfg.Session.IdleTimeout
|
||||
sessions.HashTokenInStore = true
|
||||
sessions.Cookie.Name = cfg.Session.CookieName
|
||||
sessions.Cookie.HttpOnly = true
|
||||
sessions.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sessions.Cookie.Secure = cfg.Session.CookieSecure
|
||||
|
||||
app := &application{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
models: postgres.New(db),
|
||||
mailer: mailer,
|
||||
sessions: sessions,
|
||||
db: db,
|
||||
}
|
||||
|
||||
sessions.ErrorFunc = app.serverErrorResponse
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Run serves API requests until shutdown and then releases application resources.
|
||||
func (app *application) Run() {
|
||||
defer app.db.Close()
|
||||
|
||||
err := app.serve()
|
||||
if err != nil {
|
||||
app.logger.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Version returns the version embedded in the API binary.
|
||||
func (app *application) Version() string {
|
||||
return fmt.Sprintf("Version:\t%s\n", version)
|
||||
}
|
||||
|
||||
func openDB(cfg Config) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", cfg.DB.Dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(cfg.DB.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.DB.MaxIdleConns)
|
||||
db.SetConnMaxIdleTime(cfg.DB.MaxIdleTime)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = db.PingContext(ctx)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type applicationSettingsInput struct {
|
||||
LifecycleStatusEnabled *bool `json:"lifecycle_status_enabled"`
|
||||
LifecycleRemovalMonth *int `json:"lifecycle_removal_month"`
|
||||
LifecycleRemovalDay *int `json:"lifecycle_removal_day"`
|
||||
Timezone *string `json:"timezone"`
|
||||
}
|
||||
|
||||
func (input applicationSettingsInput) apply(settings *storage.ApplicationSettings) {
|
||||
if input.LifecycleStatusEnabled != nil {
|
||||
settings.LifecycleStatusEnabled = *input.LifecycleStatusEnabled
|
||||
}
|
||||
if input.LifecycleRemovalMonth != nil {
|
||||
settings.LifecycleRemovalMonth = *input.LifecycleRemovalMonth
|
||||
}
|
||||
if input.LifecycleRemovalDay != nil {
|
||||
settings.LifecycleRemovalDay = *input.LifecycleRemovalDay
|
||||
}
|
||||
if input.Timezone != nil {
|
||||
settings.Timezone = strings.TrimSpace(*input.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := app.models.ApplicationSettings.Get()
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := app.models.ApplicationSettings.Get()
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input applicationSettingsInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&settings)
|
||||
v := validate.New()
|
||||
storage.ValidateApplicationSettings(v, settings)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
settings, err = app.models.ApplicationSettings.Update(settings)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) runLifecycleMaintenance(now time.Time) error {
|
||||
if app.models.ApplicationSettings == nil {
|
||||
return nil
|
||||
}
|
||||
settings, err := app.models.ApplicationSettings.Get()
|
||||
if err != nil || !settings.LifecycleStatusEnabled {
|
||||
return err
|
||||
}
|
||||
location, err := time.LoadLocation(settings.Timezone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localNow := now.In(location)
|
||||
lastDay := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth)+1, 0, 0, 0, 0, 0, location).Day()
|
||||
day := settings.LifecycleRemovalDay
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
cutoff := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth), day, 0, 0, 0, 0, location)
|
||||
if localNow.Before(cutoff) {
|
||||
return nil
|
||||
}
|
||||
count, err := app.models.ApplicationSettings.RemoveExpiredPlants(cutoff, settings.LifecycleRemovalMonth, day)
|
||||
if err == nil && count > 0 {
|
||||
app.logger.Info("removed plants which reached their configured lifecycle", "count", count, "cutoff", cutoff.Format("2006-01-02"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type applicationSettingsTestModel struct {
|
||||
settings storage.ApplicationSettings
|
||||
calls int
|
||||
asOf time.Time
|
||||
}
|
||||
|
||||
func (m *applicationSettingsTestModel) Get() (storage.ApplicationSettings, error) {
|
||||
return m.settings, nil
|
||||
}
|
||||
func (m *applicationSettingsTestModel) Update(value storage.ApplicationSettings) (storage.ApplicationSettings, error) {
|
||||
m.settings = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *applicationSettingsTestModel) RemoveExpiredPlants(asOf time.Time, _, _ int) (int, error) {
|
||||
m.calls++
|
||||
m.asOf = asOf
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
func TestLifecycleMaintenanceHonorsEnabledSettingAndCutoff(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
model := &applicationSettingsTestModel{settings: storage.ApplicationSettings{LifecycleStatusEnabled: true, LifecycleRemovalMonth: 12, LifecycleRemovalDay: 1, Timezone: "Europe/Berlin"}}
|
||||
app.models.ApplicationSettings = model
|
||||
|
||||
if err := app.runLifecycleMaintenance(time.Date(2026, 11, 30, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model.calls != 0 {
|
||||
t.Fatalf("maintenance ran before cutoff")
|
||||
}
|
||||
if err := app.runLifecycleMaintenance(time.Date(2026, 12, 1, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model.calls != 1 || model.asOf.Format("2006-01-02") != "2026-12-01" {
|
||||
t.Fatalf("maintenance calls=%d cutoff=%v", model.calls, model.asOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAdminEnvironmentMasksSecrets(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
app.config = Config{
|
||||
DB: DatabaseConfig{Dsn: "postgres://user:secret@db/gardomatic"},
|
||||
Mail: mailer.Config{Password: "smtp-secret"},
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/admin/environment", nil)
|
||||
|
||||
app.showAdminEnvironmentHandler(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if strings.Contains(body, "secret") {
|
||||
t.Fatalf("environment response exposes a secret: %s", body)
|
||||
}
|
||||
var result struct {
|
||||
Variables []environmentVariable `json:"variables"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
|
||||
found := false
|
||||
for _, variable := range result.Variables {
|
||||
found = found || variable.Name == name
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing environment variable %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAdminTestMailValidatesAndDelivers(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
mailPath := filepath.Join(t.TempDir(), "mail.log")
|
||||
configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.mailer = configuredMailer
|
||||
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":"not-an-email"}`))
|
||||
app.sendAdminTestMailHandler(invalidResponse, invalidRequest)
|
||||
if invalidResponse.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("invalid status: got %d, want %d; body: %s", invalidResponse.Code, http.StatusUnprocessableEntity, invalidResponse.Body.String())
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":" Test@Example.com "}`))
|
||||
app.sendAdminTestMailHandler(response, request)
|
||||
if response.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusAccepted, response.Body.String())
|
||||
}
|
||||
content, err := os.ReadFile(mailPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"test@example.com", "Gardomatic Testmail", "Gardomatic funktion"} {
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Errorf("test mail is missing %q: %s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package api
|
||||
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type careInstructionInput struct {
|
||||
Text *string `json:"text"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func (i careInstructionInput) apply(v *storage.CareInstruction) {
|
||||
if i.Text != nil {
|
||||
v.Text = strings.TrimSpace(*i.Text)
|
||||
}
|
||||
if i.Status != nil {
|
||||
v.Status = *i.Status
|
||||
}
|
||||
}
|
||||
func (app *application) validateCareInstruction(w http.ResponseWriter, r *http.Request, item storage.CareInstruction) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateCareInstruction(v, item)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (app *application) listCareInstructionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
g, _ := app.readGardenIDParam(r)
|
||||
s, e := app.readIDParam(r)
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
items, e := app.models.CareInstructions.GetAllForSpecies(g, s)
|
||||
if e != nil {
|
||||
app.respondToEntityModelError(w, r, e)
|
||||
return
|
||||
}
|
||||
if e = app.writeJSON(w, http.StatusOK, envelope{"care_instructions": items}, nil); e != nil {
|
||||
app.serverErrorResponse(w, r, e)
|
||||
}
|
||||
}
|
||||
func (app *application) createCareInstructionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
g, _ := app.readGardenIDParam(r)
|
||||
s, e := app.readIDParam(r)
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if !app.canWriteCareInstructions(w, r, g, s) {
|
||||
return
|
||||
}
|
||||
var input careInstructionInput
|
||||
if e = app.readJSON(w, r, &input); e != nil {
|
||||
app.badRequestResponse(w, r, e)
|
||||
return
|
||||
}
|
||||
u, _ := app.contextGetAuthenticatedUser(r)
|
||||
item := storage.CareInstruction{SpeciesID: s, Status: "untested", CreatedBy: u.ID, UpdatedBy: u.ID}
|
||||
input.apply(&item)
|
||||
if !app.validateCareInstruction(w, r, item) {
|
||||
return
|
||||
}
|
||||
item, e = app.models.CareInstructions.Insert(item)
|
||||
if e != nil {
|
||||
app.respondToEntityModelError(w, r, e)
|
||||
return
|
||||
}
|
||||
h := make(http.Header)
|
||||
h.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/care-instructions/%d", g, s, item.ID))
|
||||
_ = app.writeJSON(w, http.StatusCreated, envelope{"care_instruction": item}, h)
|
||||
}
|
||||
func (app *application) updateCareInstructionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
g, _ := app.readGardenIDParam(r)
|
||||
s, e := app.readIDParam(r)
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if !app.canWriteCareInstructions(w, r, g, s) {
|
||||
return
|
||||
}
|
||||
id, e := app.readNamedIDParam(r, "instructionID")
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
item, e := app.models.CareInstructions.Get(g, s, id)
|
||||
if e != nil {
|
||||
app.respondToEntityModelError(w, r, e)
|
||||
return
|
||||
}
|
||||
var input careInstructionInput
|
||||
if e = app.readJSON(w, r, &input); e != nil {
|
||||
app.badRequestResponse(w, r, e)
|
||||
return
|
||||
}
|
||||
input.apply(&item)
|
||||
u, _ := app.contextGetAuthenticatedUser(r)
|
||||
item.UpdatedBy = u.ID
|
||||
if !app.validateCareInstruction(w, r, item) {
|
||||
return
|
||||
}
|
||||
item, e = app.models.CareInstructions.Update(g, item)
|
||||
if e != nil {
|
||||
app.respondToEntityModelError(w, r, e)
|
||||
return
|
||||
}
|
||||
_ = app.writeJSON(w, http.StatusOK, envelope{"care_instruction": item}, nil)
|
||||
}
|
||||
func (app *application) deleteCareInstructionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
g, _ := app.readGardenIDParam(r)
|
||||
s, e := app.readIDParam(r)
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if !app.canWriteCareInstructions(w, r, g, s) {
|
||||
return
|
||||
}
|
||||
id, e := app.readNamedIDParam(r, "instructionID")
|
||||
if e != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if e = app.models.CareInstructions.Delete(g, s, id); e != nil {
|
||||
app.respondToEntityModelError(w, r, e)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) canWriteCareInstructions(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) bool {
|
||||
species, err := app.models.Species.Get(gardenID, speciesID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return false
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if species.GardenID == nil {
|
||||
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return false
|
||||
}
|
||||
} else if !member.Can(storage.GardenPermissionSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const authenticatedUserContextKey = contextKey("authenticatedUser")
|
||||
const gardenMemberContextKey = contextKey("gardenMember")
|
||||
|
||||
func (app *application) contextSetAuthenticatedUser(r *http.Request, user storage.User) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), authenticatedUserContextKey, user)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (app *application) contextGetAuthenticatedUser(r *http.Request) (storage.User, bool) {
|
||||
user, ok := r.Context().Value(authenticatedUserContextKey).(storage.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func (app *application) contextSetGardenMember(r *http.Request, member storage.GardenMember) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), gardenMemberContextKey, member)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (app *application) contextGetGardenMember(r *http.Request) (storage.GardenMember, bool) {
|
||||
member, ok := r.Context().Value(gardenMemberContextKey).(storage.GardenMember)
|
||||
return member, ok
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package api implements the Gardomatic HTTP API, including authentication,
|
||||
// authorization middleware, request validation, and JSON response handling.
|
||||
package api
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func (app *application) logError(r *http.Request, err error) {
|
||||
var (
|
||||
method = r.Method
|
||||
uri = r.URL.RequestURI()
|
||||
)
|
||||
|
||||
app.logger.Error(err.Error(), "method", method, "uri", uri)
|
||||
fmt.Printf("%v\n", string(debug.Stack()))
|
||||
}
|
||||
|
||||
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) {
|
||||
env := envelope{"error": message}
|
||||
|
||||
err := app.writeJSON(w, status, env, nil)
|
||||
if err != nil {
|
||||
app.logError(r, err)
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
app.logError(r, err)
|
||||
|
||||
message := "the server encountered a problem and could not process your request"
|
||||
app.errorResponse(w, r, http.StatusInternalServerError, message)
|
||||
}
|
||||
|
||||
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "the requested resource could not be found"
|
||||
app.errorResponse(w, r, http.StatusNotFound, message)
|
||||
}
|
||||
|
||||
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
|
||||
app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
|
||||
}
|
||||
|
||||
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
|
||||
app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
|
||||
}
|
||||
|
||||
func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "unable to update the record due to an edit conflict, please try again"
|
||||
app.errorResponse(w, r, http.StatusConflict, message)
|
||||
}
|
||||
|
||||
func (app *application) conflictResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "a conflicting record already exists"
|
||||
app.errorResponse(w, r, http.StatusConflict, message)
|
||||
}
|
||||
|
||||
func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "rate limit exceeded"
|
||||
app.errorResponse(w, r, http.StatusTooManyRequests, message)
|
||||
}
|
||||
|
||||
func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "invalid authentication credentials"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
|
||||
message := "invalid or missing authentication token"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "you must be authenticated to access this resource"
|
||||
app.errorResponse(w, r, http.StatusUnauthorized, message)
|
||||
}
|
||||
|
||||
func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "your user account must be activated to access this resource"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
|
||||
func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "you do not have permission to perform this action"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
|
||||
func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) {
|
||||
message := "the request origin is not trusted"
|
||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func (app *application) listGardenMembersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
members, err := app.models.GardenMembers.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"members": members}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func canManageMember(target, replacement storage.GardenRole) bool {
|
||||
return target != storage.GardenRoleOwner && replacement != storage.GardenRoleOwner
|
||||
}
|
||||
|
||||
func (app *application) updateGardenMemberHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
userID, err := app.readNamedIDParam(r, "userID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Role storage.GardenRole `json:"role"`
|
||||
}
|
||||
if err = app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
_, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role))
|
||||
if roleErr != nil || input.Role == storage.GardenRoleOwner {
|
||||
app.failedValidationResponse(w, r, map[string]string{"role": "must be a garden role; ownership must be transferred separately"})
|
||||
return
|
||||
}
|
||||
target, err := app.models.GardenMembers.Get(gardenID, userID)
|
||||
if err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !canManageMember(target.Role, input.Role) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
target.Role = input.Role
|
||||
target, err = app.models.GardenMembers.Update(target)
|
||||
if err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"member": target}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteGardenMemberHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
userID, err := app.readNamedIDParam(r, "userID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
target, err := app.models.GardenMembers.Get(gardenID, userID)
|
||||
if err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !canManageMember(target.Role, storage.GardenRoleViewer) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.GardenMembers.Delete(gardenID, userID); err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) transferGardenOwnershipHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
toUserID, err := app.readNamedIDParam(r, "userID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
actor, _ := app.contextGetGardenMember(r)
|
||||
if err = app.models.GardenMembers.TransferOwnership(gardenID, actor.UserID, toUserID); err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) listGardenInvitesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
invites, err := app.models.GardenInvites.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"invites": invites}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createGardenInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
actor, _ := app.contextGetGardenMember(r)
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Role storage.GardenRole `json:"role"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
v := validate.New()
|
||||
storage.ValidateEmail(v, input.Email)
|
||||
_, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role))
|
||||
if roleErr != nil || input.Role == storage.GardenRoleOwner {
|
||||
v.AddError("role", "role may not be granted")
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
invite, err := app.models.GardenInvites.Upsert(storage.GardenInvite{GardenID: gardenID, Email: input.Email, Role: input.Role, InvitedBy: actor.UserID, ExpiresAt: time.Now().Add(7 * 24 * time.Hour)})
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
app.background(func() {
|
||||
data := map[string]any{"inviteURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token}
|
||||
if sendErr := app.mailer.Send(invite.Email, "garden_invite.tmpl", data); sendErr != nil {
|
||||
app.logger.Error(sendErr.Error())
|
||||
}
|
||||
})
|
||||
invite.Token = ""
|
||||
if err = app.writeJSON(w, http.StatusCreated, envelope{"invite": invite}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteGardenInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
inviteID, err := app.readNamedIDParam(r, "inviteID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.GardenInvites.Delete(gardenID, inviteID); err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) acceptGardenInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
token := httprouter.ParamsFromContext(r.Context()).ByName("token")
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
member, err := app.models.GardenInvites.Accept(token, user)
|
||||
if err != nil {
|
||||
app.respondToMemberError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"member": member}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) respondToMemberError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
case errors.Is(err, storage.ErrConflict):
|
||||
app.conflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) createGardenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data"`
|
||||
ImageID *int `json:"image_id"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
garden := storage.Garden{
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
ImageData: input.ImageData,
|
||||
}
|
||||
v := validate.New()
|
||||
validateImageData(v, garden.ImageData)
|
||||
if storage.ValidateGarden(v, garden); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
garden, err := app.models.Gardens.Insert(garden, user.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.ImageData != "" || input.ImageID != nil {
|
||||
garden.ImageID, err = app.resolveImage(garden.ID, input.ImageData, input.ImageID, user.ID, "garden")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
garden.ImageData = ""
|
||||
garden, err = app.models.Gardens.Update(garden)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
garden.Role = storage.GardenRoleOwner
|
||||
garden.Permissions = storage.GardenRoleOwner.Permissions()
|
||||
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d", garden.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"garden": garden}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listGardensHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
gardens, err := app.models.Gardens.GetAllForUser(user.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range gardens {
|
||||
member, memberErr := app.models.GardenMembers.Get(gardens[i].ID, user.ID)
|
||||
if memberErr != nil {
|
||||
app.serverErrorResponse(w, r, memberErr)
|
||||
return
|
||||
}
|
||||
applyGardenMembership(&gardens[i], member)
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"gardens": gardens}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showGardenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
garden, err := app.models.Gardens.Get(gardenID)
|
||||
if err != nil {
|
||||
app.respondToGardenModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
applyGardenMembership(&garden, member)
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateGardenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
garden, err := app.models.Gardens.Get(gardenID)
|
||||
if err != nil {
|
||||
app.respondToGardenModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
applyGardenMembership(&garden, member)
|
||||
|
||||
var input struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageData *string `json:"image_data"`
|
||||
ImageID *int `json:"image_id"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Name != nil {
|
||||
garden.Name = strings.TrimSpace(*input.Name)
|
||||
}
|
||||
if input.Description != nil {
|
||||
garden.Description = strings.TrimSpace(*input.Description)
|
||||
}
|
||||
previousImageID := garden.ImageID
|
||||
previousImageData := garden.ImageData
|
||||
if input.ImageData != nil {
|
||||
garden.ImageData = *input.ImageData
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
validateImageData(v, garden.ImageData)
|
||||
if storage.ValidateGarden(v, garden); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if input.ImageData != nil && *input.ImageData != previousImageData {
|
||||
garden.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "garden")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
garden.ImageData = ""
|
||||
garden, err = app.models.Gardens.Update(garden)
|
||||
if err != nil {
|
||||
app.respondToGardenModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "garden", EntityID: garden.ID, PreviousImageID: previousImageID, ImageID: garden.ImageID, ChangedBy: user.ID}); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyGardenMembership(garden *storage.Garden, member storage.GardenMember) {
|
||||
garden.Role = member.Role
|
||||
garden.Permissions = member.Permissions
|
||||
if garden.Permissions == nil {
|
||||
garden.Permissions = member.Role.Permissions()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteGardenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
if err := app.models.Gardens.Delete(gardenID); err != nil {
|
||||
app.respondToGardenModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) respondToGardenModelError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
case errors.Is(err, storage.ErrEditConflict):
|
||||
app.editConflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type gardenTestModel struct {
|
||||
gardens map[int]storage.Garden
|
||||
nextID int
|
||||
insertOwner int
|
||||
listUserID int
|
||||
getCallCount int
|
||||
}
|
||||
|
||||
func (m *gardenTestModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) {
|
||||
m.nextID++
|
||||
garden.ID = m.nextID
|
||||
garden.Version = 1
|
||||
garden.CreatedAt = time.Now()
|
||||
garden.UpdatedAt = garden.CreatedAt
|
||||
m.gardens[garden.ID] = garden
|
||||
m.insertOwner = ownerID
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func (m *gardenTestModel) Get(id int) (storage.Garden, error) {
|
||||
m.getCallCount++
|
||||
garden, ok := m.gardens[id]
|
||||
if !ok {
|
||||
return storage.Garden{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func (m *gardenTestModel) GetAllForUser(userID int) ([]storage.Garden, error) {
|
||||
m.listUserID = userID
|
||||
gardens := make([]storage.Garden, 0, len(m.gardens))
|
||||
for _, garden := range m.gardens {
|
||||
gardens = append(gardens, garden)
|
||||
}
|
||||
return gardens, nil
|
||||
}
|
||||
|
||||
func (m *gardenTestModel) Update(garden storage.Garden) (storage.Garden, error) {
|
||||
if _, ok := m.gardens[garden.ID]; !ok {
|
||||
return storage.Garden{}, storage.ErrRecordNotFound
|
||||
}
|
||||
garden.Version++
|
||||
m.gardens[garden.ID] = garden
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func (m *gardenTestModel) Delete(id int) error {
|
||||
if _, ok := m.gardens[id]; !ok {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
delete(m.gardens, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type gardenMemberTestModel struct {
|
||||
members map[[2]int]storage.GardenMember
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
m.members[[2]int{member.GardenID, member.UserID}] = member
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) Get(gardenID, userID int) (storage.GardenMember, error) {
|
||||
member, ok := m.members[[2]int{gardenID, userID}]
|
||||
if !ok {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) GetAllForGarden(int) ([]storage.GardenMember, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) Delete(gardenID, userID int) error {
|
||||
delete(m.members, [2]int{gardenID, userID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *gardenMemberTestModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
|
||||
from := m.members[[2]int{gardenID, fromUserID}]
|
||||
to := m.members[[2]int{gardenID, toUserID}]
|
||||
from.Role = storage.GardenRoleAdmin
|
||||
to.Role = storage.GardenRoleOwner
|
||||
m.members[[2]int{gardenID, fromUserID}] = from
|
||||
m.members[[2]int{gardenID, toUserID}] = to
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGardenTestApplication() (*application, *gardenTestModel, *gardenMemberTestModel) {
|
||||
gardens := &gardenTestModel{gardens: make(map[int]storage.Garden), nextID: 40}
|
||||
members := &gardenMemberTestModel{members: make(map[[2]int]storage.GardenMember)}
|
||||
app := &application{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
models: storage.Models{Gardens: gardens, GardenMembers: members},
|
||||
}
|
||||
return app, gardens, members
|
||||
}
|
||||
|
||||
func serveGardenRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
router := httprouter.New()
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.updateGardenHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.deleteGardenHandler)))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = app.contextSetAuthenticatedUser(r, user)
|
||||
router.ServeHTTP(w, r)
|
||||
})
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestCreateAndListGardens(t *testing.T) {
|
||||
app, gardens, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}}
|
||||
|
||||
createResponse := serveGardenRequest(app, user, http.MethodPost, "/v1/gardens", []byte(`{"name":" Hinterhof ","description":" Gemüse "}`))
|
||||
if createResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("create status: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String())
|
||||
}
|
||||
if gardens.insertOwner != user.ID {
|
||||
t.Errorf("owner: got %d, want %d", gardens.insertOwner, user.ID)
|
||||
}
|
||||
if got := createResponse.Header().Get("Location"); got != "/v1/gardens/41" {
|
||||
t.Errorf("Location: got %q, want %q", got, "/v1/gardens/41")
|
||||
}
|
||||
|
||||
var createEnvelope struct {
|
||||
Garden storage.Garden `json:"garden"`
|
||||
}
|
||||
if err := json.Unmarshal(createResponse.Body.Bytes(), &createEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if createEnvelope.Garden.Name != "Hinterhof" || createEnvelope.Garden.Description != "Gemüse" {
|
||||
t.Errorf("created garden was not normalized: %+v", createEnvelope.Garden)
|
||||
}
|
||||
members.members[[2]int{createEnvelope.Garden.ID, user.ID}] = storage.GardenMember{GardenID: createEnvelope.Garden.ID, UserID: user.ID, Role: storage.GardenRoleOwner}
|
||||
|
||||
listResponse := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil)
|
||||
if listResponse.Code != http.StatusOK {
|
||||
t.Fatalf("list status: got %d, want %d", listResponse.Code, http.StatusOK)
|
||||
}
|
||||
if gardens.listUserID != user.ID {
|
||||
t.Errorf("list user: got %d, want %d", gardens.listUserID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGardenRequiresApplicationPermission(t *testing.T) {
|
||||
app, gardens, _ := newGardenTestApplication()
|
||||
response := serveGardenRequest(app, storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{}}, http.MethodPost, "/v1/gardens", []byte(`{"name":"Hinterhof"}`))
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusForbidden, response.Body.String())
|
||||
}
|
||||
if gardens.insertOwner != 0 {
|
||||
t.Fatal("garden was inserted without gardens:create permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenMembershipHidesForeignGarden(t *testing.T) {
|
||||
app, gardens, members := newGardenTestApplication()
|
||||
gardens.gardens[12] = storage.Garden{ID: 12, Name: "Geheim", Version: 1}
|
||||
members.members[[2]int{12, 1}] = storage.GardenMember{GardenID: 12, UserID: 1, Role: storage.GardenRoleOwner}
|
||||
|
||||
memberResponse := serveGardenRequest(app, storage.User{ID: 1, Activated: true}, http.MethodGet, "/v1/gardens/12", nil)
|
||||
if memberResponse.Code != http.StatusOK {
|
||||
t.Fatalf("member status: got %d, want %d", memberResponse.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
callsBefore := gardens.getCallCount
|
||||
foreignResponse := serveGardenRequest(app, storage.User{ID: 2, Activated: true}, http.MethodGet, "/v1/gardens/12", nil)
|
||||
if foreignResponse.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign status: got %d, want %d", foreignResponse.Code, http.StatusNotFound)
|
||||
}
|
||||
if gardens.getCallCount != callsBefore {
|
||||
t.Error("garden was loaded even though membership check failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenResponsesIncludeEffectiveMembershipPermissions(t *testing.T) {
|
||||
app, gardens, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 4, Activated: true}
|
||||
gardens.gardens[12] = storage.Garden{ID: 12, Name: "Gemeinschaftsgarten", Version: 1}
|
||||
members.members[[2]int{12, user.ID}] = storage.GardenMember{
|
||||
GardenID: 12,
|
||||
UserID: user.ID,
|
||||
Role: "garden:custom",
|
||||
Permissions: []storage.GardenPermission{storage.GardenPermissionPlantCreate, storage.GardenPermissionTaskCompleteOther},
|
||||
}
|
||||
|
||||
response := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens/12", nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("show status: got %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
var showEnvelope struct {
|
||||
Garden storage.Garden `json:"garden"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &showEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := showEnvelope.Garden.Permissions; len(got) != 2 || got[0] != storage.GardenPermissionPlantCreate || got[1] != storage.GardenPermissionTaskCompleteOther {
|
||||
t.Fatalf("show permissions: got %v", got)
|
||||
}
|
||||
|
||||
response = serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("list status: got %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
var listEnvelope struct {
|
||||
Gardens []storage.Garden `json:"gardens"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &listEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listEnvelope.Gardens) != 1 || len(listEnvelope.Gardens[0].Permissions) != 2 {
|
||||
t.Fatalf("list permissions: got %+v", listEnvelope.Gardens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAndDeleteGarden(t *testing.T) {
|
||||
app, gardens, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 3, Activated: true}
|
||||
gardens.gardens[9] = storage.Garden{ID: 9, Name: "Alt", Description: "Alt", Version: 1}
|
||||
members.members[[2]int{9, user.ID}] = storage.GardenMember{GardenID: 9, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
|
||||
updateResponse := serveGardenRequest(app, user, http.MethodPatch, "/v1/gardens/9", []byte(`{"name":"Neu"}`))
|
||||
if updateResponse.Code != http.StatusOK {
|
||||
t.Fatalf("update status: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String())
|
||||
}
|
||||
if got := gardens.gardens[9]; got.Name != "Neu" || got.Description != "Alt" || got.Version != 2 {
|
||||
t.Errorf("updated garden: got %+v", got)
|
||||
}
|
||||
|
||||
deleteResponse := serveGardenRequest(app, user, http.MethodDelete, "/v1/gardens/9", nil)
|
||||
if deleteResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status: got %d, want %d", deleteResponse.Code, http.StatusNoContent)
|
||||
}
|
||||
if _, exists := gardens.gardens[9]; exists {
|
||||
t.Error("garden still exists after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGardenValidation(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
response := serveGardenRequest(app, storage.User{ID: 1, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}}, http.MethodPost, "/v1/gardens", []byte(`{"name":" "}`))
|
||||
if response.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusUnprocessableEntity, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
env := envelope{
|
||||
"status": "available",
|
||||
"server_time": time.Now().UTC(),
|
||||
"system_info": map[string]string{
|
||||
"environment": app.config.Env,
|
||||
"version": version,
|
||||
},
|
||||
}
|
||||
|
||||
err := app.writeJSON(w, http.StatusOK, env, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthcheckIncludesServerTimeAndSystemInformation(t *testing.T) {
|
||||
app := &application{config: Config{Env: "test"}}
|
||||
request := httptest.NewRequest("GET", "/v1/healthcheck", nil)
|
||||
response := httptest.NewRecorder()
|
||||
app.healthcheckHandler(response, request)
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
SystemInfo struct {
|
||||
Environment string `json:"environment"`
|
||||
Version string `json:"version"`
|
||||
} `json:"system_info"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Status != "available" || body.ServerTime.IsZero() || body.SystemInfo.Environment != "test" || body.SystemInfo.Version == "" {
|
||||
t.Fatalf("unexpected health response: %+v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func (app *application) readIDParam(r *http.Request) (int, error) {
|
||||
return app.readNamedIDParam(r, "id")
|
||||
}
|
||||
|
||||
func (app *application) readNamedIDParam(r *http.Request, name string) (int, error) {
|
||||
params := httprouter.ParamsFromContext(r.Context())
|
||||
id, err := strconv.Atoi(params.ByName(name))
|
||||
if err != nil || id < 1 {
|
||||
return 0, errors.New("invalid id parameter")
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (app *application) readGardenIDParam(r *http.Request) (int, error) {
|
||||
params := httprouter.ParamsFromContext(r.Context())
|
||||
|
||||
id, err := strconv.Atoi(params.ByName("gardenID"))
|
||||
if err != nil || id < 1 {
|
||||
return 0, errors.New("invalid garden id parameter")
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
type envelope map[string]any
|
||||
|
||||
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
|
||||
js, err := json.MarshalIndent(data, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
js = append(js, '\n')
|
||||
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(js)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2_097_152)
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
err := dec.Decode(dst)
|
||||
if err != nil {
|
||||
var syntaxError *json.SyntaxError
|
||||
var unmarshalTypeError *json.UnmarshalTypeError
|
||||
var invalidUnmarshalError *json.InvalidUnmarshalError
|
||||
var maxBytesError *http.MaxBytesError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("body contains badly-formed JSON")
|
||||
|
||||
case errors.As(err, &unmarshalTypeError):
|
||||
if unmarshalTypeError.Field != "" {
|
||||
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
|
||||
}
|
||||
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("body must not be empty")
|
||||
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
return fmt.Errorf("body contains unknown key %s", fieldName)
|
||||
|
||||
case errors.As(err, &maxBytesError):
|
||||
return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit)
|
||||
|
||||
case errors.As(err, &invalidUnmarshalError):
|
||||
panic(err)
|
||||
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = dec.Decode(&struct{}{})
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return errors.New("body must only contain a single JSON value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateImageData(v *validate.Validator, imageData string) {
|
||||
if imageData == "" {
|
||||
return
|
||||
}
|
||||
v.Check(len(imageData) <= 1_500_000, "image_data", "must not be larger than 1.5 MB")
|
||||
v.Check(strings.HasPrefix(imageData, "data:image/jpeg;base64,") || strings.HasPrefix(imageData, "data:image/png;base64,") || strings.HasPrefix(imageData, "data:image/webp;base64,"), "image_data", "must be a JPEG, PNG or WebP image")
|
||||
}
|
||||
|
||||
//lint:ignore U1000 retained for the upcoming list filters
|
||||
func (app *application) readString(qs url.Values, key string, defaultValue string) string {
|
||||
s := qs.Get(key)
|
||||
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
//lint:ignore U1000 retained for the upcoming list filters
|
||||
func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string {
|
||||
csv := qs.Get(key)
|
||||
|
||||
if csv == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return strings.Split(csv, ",")
|
||||
}
|
||||
|
||||
//lint:ignore U1000 retained for the upcoming pagination support
|
||||
func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validate.Validator) int {
|
||||
s := qs.Get(key)
|
||||
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
v.AddError(key, "must be an integer value")
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func (app *application) background(fn func()) {
|
||||
app.wg.Go(func() {
|
||||
defer func() {
|
||||
pv := recover()
|
||||
if pv != nil {
|
||||
app.logger.Error(fmt.Sprintf("%v", pv))
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) resolveImage(gardenID int, data string, selectedID *int, userID int, source string) (*int, error) {
|
||||
if strings.TrimSpace(data) == "" {
|
||||
if selectedID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := app.models.Images.Get(gardenID, *selectedID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return selectedID, nil
|
||||
}
|
||||
comma := strings.IndexByte(data, ',')
|
||||
if comma < 1 || !strings.HasPrefix(data, "data:image/") || !strings.HasSuffix(data[:comma], ";base64") {
|
||||
return nil, errors.New("invalid image data")
|
||||
}
|
||||
mediaType := strings.TrimSuffix(strings.TrimPrefix(data[:comma], "data:"), ";base64")
|
||||
decoded, err := base64.StdEncoding.DecodeString(data[comma+1:])
|
||||
if err != nil || len(decoded) == 0 || len(decoded) > storage.MaxImageSize {
|
||||
return nil, errors.New("invalid image data")
|
||||
}
|
||||
image, err := app.models.Images.Insert(storage.Image{GardenID: gardenID, MediaType: mediaType, Data: decoded, Source: source, CreatedBy: userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &image.ID, nil
|
||||
}
|
||||
|
||||
func (app *application) recordImageAssignment(change storage.ImageAssignment) error {
|
||||
if app.models.Images == nil {
|
||||
return nil
|
||||
}
|
||||
return app.models.Images.RecordAssignment(change)
|
||||
}
|
||||
|
||||
func (app *application) listImagesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
images, err := app.models.Images.GetAllForGarden(gardenID, storage.ImageFilter{Source: r.URL.Query().Get("source"), Query: r.URL.Query().Get("q")})
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"images": images}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showImageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readNamedIDParam(r, "imageID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
image, err := app.models.Images.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", image.MediaType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
_, _ = w.Write(image.Data)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type journalEntryInput struct {
|
||||
Title *string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
Tags []string `json:"tags"`
|
||||
EntryType *storage.JournalEntryType `json:"entry_type"`
|
||||
}
|
||||
|
||||
func (input journalEntryInput) apply(entry *storage.JournalEntry) {
|
||||
if input.Title != nil {
|
||||
entry.Title = strings.TrimSpace(*input.Title)
|
||||
}
|
||||
if input.Body != nil {
|
||||
entry.Body = strings.TrimSpace(*input.Body)
|
||||
}
|
||||
if input.CreatedAt != nil {
|
||||
entry.CreatedAt = input.CreatedAt.UTC()
|
||||
}
|
||||
if input.EntryType != nil {
|
||||
entry.EntryType = *input.EntryType
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createJournalEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input journalEntryInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
entry := storage.JournalEntry{GardenID: gardenID, AuthorID: user.ID, AuthorName: user.Name, EntryType: storage.JournalEntryTypeJournal}
|
||||
entry.CreatedAt = time.Now().UTC()
|
||||
input.apply(&entry)
|
||||
entry.Tags = storage.NormalizeTags(input.Tags)
|
||||
if !app.validateJournalEntry(w, r, entry) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
entry, err = app.models.Journal.Insert(entry)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, entry.ID, entry.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/journal/%d", gardenID, entry.ID))
|
||||
if err = app.writeJSON(w, http.StatusCreated, envelope{"journal_entry": entry}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listJournalEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
entryType := storage.JournalEntryType(r.URL.Query().Get("type"))
|
||||
if entryType == "" {
|
||||
entryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
if entryType != storage.JournalEntryTypeJournal && entryType != storage.JournalEntryTypePinboard {
|
||||
app.badRequestResponse(w, r, fmt.Errorf("type must be journal or pinboard"))
|
||||
return
|
||||
}
|
||||
entries, err := app.models.Journal.GetAllForGarden(gardenID, entryType)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entries[i].ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entries": entries}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showJournalEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
entry, err := app.models.Journal.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entry.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateJournalEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
entry, err := app.models.Journal.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input journalEntryInput
|
||||
if err = app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&entry)
|
||||
if input.Tags != nil {
|
||||
entry.Tags = storage.NormalizeTags(input.Tags)
|
||||
} else {
|
||||
entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, id)
|
||||
}
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.validateJournalEntry(w, r, entry) {
|
||||
return
|
||||
}
|
||||
entry, err = app.models.Journal.Update(gardenID, entry)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Tags != nil {
|
||||
entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, id, entry.Tags)
|
||||
}
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if stored, getErr := app.models.Journal.Get(gardenID, id); getErr == nil {
|
||||
entry.Attachments = stored.Attachments
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteJournalEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.Journal.Delete(gardenID, id); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) createJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
entryID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxJournalAttachmentSize+(1<<20))
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, fmt.Errorf("file must be provided"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, storage.MaxJournalAttachmentSize+1))
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(data) > storage.MaxJournalAttachmentSize {
|
||||
app.badRequestResponse(w, r, fmt.Errorf("file must not be larger than 25 MB"))
|
||||
return
|
||||
}
|
||||
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0]))
|
||||
detected := http.DetectContentType(data)
|
||||
if mediaType == "" || mediaType == "application/octet-stream" {
|
||||
mediaType = detected
|
||||
}
|
||||
if !allowedJournalMediaType(mediaType) {
|
||||
app.badRequestResponse(w, r, fmt.Errorf("only photos, videos and audio files are supported"))
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.ReplaceAll(header.Filename, "\\", "/"))
|
||||
if name == "." || name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
if len(name) > 255 {
|
||||
name = name[:255]
|
||||
}
|
||||
attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: mediaType, Data: data})
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createJournalLibraryAttachmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
entryID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ImageID int `json:"image_id"`
|
||||
}
|
||||
if err = app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.ImageID < 1 {
|
||||
app.badRequestResponse(w, r, fmt.Errorf("image_id must be provided"))
|
||||
return
|
||||
}
|
||||
image, err := app.models.Images.Get(gardenID, input.ImageID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
name := image.FileName
|
||||
if name == "" {
|
||||
name = "Bild"
|
||||
}
|
||||
attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: image.MediaType, Size: image.Size, ImageID: &image.ID})
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func allowedJournalMediaType(value string) bool {
|
||||
switch value {
|
||||
case "image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/mp4", "audio/ogg", "audio/webm", "audio/wav", "audio/x-wav":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
entryID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
attachmentID, err := app.readNamedIDParam(r, "attachmentID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
a, err := app.models.Journal.GetAttachment(gardenID, entryID, attachmentID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", a.MediaType)
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": a.FileName}))
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeContent(w, r, a.FileName, a.CreatedAt, bytes.NewReader(a.Data))
|
||||
}
|
||||
|
||||
func (app *application) deleteJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
entryID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
attachmentID, err := app.readNamedIDParam(r, "attachmentID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.Journal.DeleteAttachment(gardenID, entryID, attachmentID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) validateJournalEntry(w http.ResponseWriter, r *http.Request, entry storage.JournalEntry) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateJournalEntry(v, entry)
|
||||
storage.ValidateTags(v, entry.Tags)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type journalTestModel struct {
|
||||
items map[int]storage.JournalEntry
|
||||
nextID int
|
||||
lastListType storage.JournalEntryType
|
||||
}
|
||||
|
||||
func (m *journalTestModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
m.nextID++
|
||||
entry.ID, entry.Version = m.nextID, 1
|
||||
m.items[entry.ID] = entry
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *journalTestModel) Get(gardenID, id int) (storage.JournalEntry, error) {
|
||||
entry, ok := m.items[id]
|
||||
if !ok || entry.GardenID != gardenID {
|
||||
return storage.JournalEntry{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *journalTestModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) {
|
||||
m.lastListType = entryType
|
||||
entries := []storage.JournalEntry{}
|
||||
for _, entry := range m.items {
|
||||
if entry.GardenID == gardenID && entry.EntryType == entryType {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m *journalTestModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
if _, err := m.Get(gardenID, entry.ID); err != nil {
|
||||
return storage.JournalEntry{}, err
|
||||
}
|
||||
entry.Version++
|
||||
m.items[entry.ID] = entry
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *journalTestModel) Delete(gardenID, id int) error {
|
||||
if _, err := m.Get(gardenID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *journalTestModel) InsertAttachment(int, int, storage.JournalAttachment) (storage.JournalAttachment, error) {
|
||||
return storage.JournalAttachment{}, nil
|
||||
}
|
||||
func (m *journalTestModel) GetAttachment(int, int, int) (storage.JournalAttachment, error) {
|
||||
return storage.JournalAttachment{}, storage.ErrRecordNotFound
|
||||
}
|
||||
func (m *journalTestModel) DeleteAttachment(int, int, int) error { return nil }
|
||||
|
||||
type journalTagTestModel struct{}
|
||||
|
||||
func (journalTagTestModel) Get(int, storage.TagEntity, int) ([]string, error) { return nil, nil }
|
||||
func (journalTagTestModel) Set(_ int, _ storage.TagEntity, _ int, tags []string) ([]string, error) {
|
||||
return tags, nil
|
||||
}
|
||||
func (journalTagTestModel) GetAllForGarden(int) ([]string, error) { return nil, nil }
|
||||
|
||||
func serveJournalRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
router := httprouter.New()
|
||||
protect := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return app.requireActivatedUser(app.requireGardenMember(handler))
|
||||
}
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", protect(app.createJournalEntryHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", protect(app.listJournalEntriesHandler))
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, app.contextSetAuthenticatedUser(request, user))
|
||||
return response
|
||||
}
|
||||
|
||||
func TestJournalAPISeparatesPinboardEntries(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 12, Name: "Ada", Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
model := &journalTestModel{items: map[int]storage.JournalEntry{
|
||||
1: {ID: 1, GardenID: 3, EntryType: storage.JournalEntryTypeJournal, Title: "Ernte"},
|
||||
2: {ID: 2, GardenID: 3, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"},
|
||||
}, nextID: 2}
|
||||
app.models.Journal = model
|
||||
app.models.Tags = journalTagTestModel{}
|
||||
|
||||
listed := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=pinboard", nil)
|
||||
if listed.Code != http.StatusOK || model.lastListType != storage.JournalEntryTypePinboard {
|
||||
t.Fatalf("list pinboard: status=%d type=%q body=%s", listed.Code, model.lastListType, listed.Body.String())
|
||||
}
|
||||
if body := listed.Body.String(); !bytes.Contains([]byte(body), []byte("Sitzecke")) || bytes.Contains([]byte(body), []byte("Ernte")) {
|
||||
t.Fatalf("list mixes entry types: %s", body)
|
||||
}
|
||||
|
||||
created := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"title":"Teichidee","entry_type":"pinboard"}`))
|
||||
if created.Code != http.StatusCreated || model.items[3].EntryType != storage.JournalEntryTypePinboard {
|
||||
t.Fatalf("create pinboard: status=%d entry=%+v body=%s", created.Code, model.items[3], created.Body.String())
|
||||
}
|
||||
|
||||
imageOnly := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"pinboard"}`))
|
||||
if imageOnly.Code != http.StatusCreated || model.items[4].Title != "" {
|
||||
t.Fatalf("create titleless pinboard entry: status=%d entry=%+v body=%s", imageOnly.Code, model.items[4], imageOnly.Body.String())
|
||||
}
|
||||
untitledJournal := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"journal"}`))
|
||||
if untitledJournal.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("untitled journal status: got %d, want %d", untitledJournal.Code, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
invalid := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=unknown", nil)
|
||||
if invalid.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid type: got %d, want %d", invalid.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type locationInput struct {
|
||||
ParentID *int `json:"parent_id"`
|
||||
ClearParentID bool `json:"clear_parent_id"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageData *string `json:"image_data"`
|
||||
ImageID *int `json:"image_id"`
|
||||
Kind *string `json:"kind"`
|
||||
AreaSQM *float64 `json:"area_sqm"`
|
||||
SunExposure *string `json:"sun_exposure"`
|
||||
SoilCondition *string `json:"soil_condition"`
|
||||
SoilReaction *string `json:"soil_reaction"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
}
|
||||
|
||||
func (input locationInput) apply(location *storage.Location) {
|
||||
if input.ParentID != nil {
|
||||
location.ParentID = input.ParentID
|
||||
}
|
||||
if input.ClearParentID {
|
||||
location.ParentID = nil
|
||||
}
|
||||
if input.Name != nil {
|
||||
location.Name = strings.TrimSpace(*input.Name)
|
||||
}
|
||||
if input.Description != nil {
|
||||
location.Description = strings.TrimSpace(*input.Description)
|
||||
}
|
||||
if input.ImageData != nil {
|
||||
location.ImageData = *input.ImageData
|
||||
}
|
||||
if input.Kind != nil {
|
||||
location.Kind = strings.TrimSpace(*input.Kind)
|
||||
}
|
||||
if input.AreaSQM != nil {
|
||||
location.AreaSQM = input.AreaSQM
|
||||
}
|
||||
if input.SunExposure != nil {
|
||||
assignStringPointer(input.SunExposure, &location.SunExposure)
|
||||
}
|
||||
assignStringPointer(input.SoilCondition, &location.SoilCondition)
|
||||
assignStringPointer(input.SoilReaction, &location.SoilReaction)
|
||||
if len(input.Attributes) != 0 {
|
||||
location.Attributes = input.Attributes
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
var input locationInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
location := storage.Location{GardenID: gardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID}
|
||||
input.apply(&location)
|
||||
if !app.validateLocationForGarden(w, r, gardenID, location) {
|
||||
return
|
||||
}
|
||||
imageID, err := app.resolveImage(gardenID, location.ImageData, input.ImageID, user.ID, "location")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
location.ImageID = imageID
|
||||
location.ImageData = ""
|
||||
location, err = app.models.Locations.Insert(location)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/locations/%d", gardenID, location.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"location": location}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listLocationsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
locations, err := app.models.Locations.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
visible := locations[:0]
|
||||
for _, location := range locations {
|
||||
if location.CreatedBy == user.ID && member.Can(storage.GardenPermissionLocationReadOwn) || location.CreatedBy != user.ID && member.Can(storage.GardenPermissionLocationReadOther) {
|
||||
visible = append(visible, location)
|
||||
}
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"locations": visible}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
location, err := app.models.Locations.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationReadOwn, storage.GardenPermissionLocationReadOther) {
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
location, err := app.models.Locations.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationUpdateOwn, storage.GardenPermissionLocationUpdateOther) {
|
||||
return
|
||||
}
|
||||
var input locationInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
previousImageID := location.ImageID
|
||||
previousImageData := location.ImageData
|
||||
input.apply(&location)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
location.UpdatedBy = user.ID
|
||||
if !app.validateLocationForGarden(w, r, gardenID, location) {
|
||||
return
|
||||
}
|
||||
if input.ImageData != nil && *input.ImageData != previousImageData {
|
||||
location.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "location")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
location.ImageData = ""
|
||||
location, err = app.models.Locations.Update(gardenID, location)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "location", EntityID: location.ID, PreviousImageID: previousImageID, ImageID: location.ImageID, ChangedBy: user.ID}); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
location, err := app.models.Locations.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationDeleteOwn, storage.GardenPermissionLocationDeleteOther) {
|
||||
return
|
||||
}
|
||||
if err := app.models.Locations.Delete(gardenID, id); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) validateLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, location storage.Location) bool {
|
||||
v := validate.New()
|
||||
validateImageData(v, location.ImageData)
|
||||
storage.ValidateLocation(v, location)
|
||||
if location.ParentID != nil && *location.ParentID > 0 && *location.ParentID != location.ID {
|
||||
if _, err := app.models.Locations.Get(gardenID, *location.ParentID); err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
v.AddError("parent_id", "must refer to a location in this garden")
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.Valid() && location.ParentID != nil && location.ID > 0 {
|
||||
locations, err := app.models.Locations.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
parents := make(map[int]*int, len(locations))
|
||||
for i := range locations {
|
||||
parents[locations[i].ID] = locations[i].ParentID
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for current := location.ParentID; current != nil; current = parents[*current] {
|
||||
if *current == location.ID || seen[*current] {
|
||||
v.AddError("parent_id", "must not create a cycle")
|
||||
break
|
||||
}
|
||||
seen[*current] = true
|
||||
}
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type locationTestModel struct {
|
||||
items map[int]storage.Location
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (m *locationTestModel) Insert(value storage.Location) (storage.Location, error) {
|
||||
m.nextID++
|
||||
value.ID = m.nextID
|
||||
value.Version = 1
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *locationTestModel) Get(gardenID, id int) (storage.Location, error) {
|
||||
value, ok := m.items[id]
|
||||
if !ok || value.GardenID != gardenID {
|
||||
return storage.Location{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (m *locationTestModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
|
||||
result := []storage.Location{}
|
||||
for _, value := range m.items {
|
||||
if value.GardenID == gardenID {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (m *locationTestModel) Update(gardenID int, value storage.Location) (storage.Location, error) {
|
||||
if _, err := m.Get(gardenID, value.ID); err != nil {
|
||||
return storage.Location{}, err
|
||||
}
|
||||
value.Version++
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *locationTestModel) Delete(gardenID, id int) error {
|
||||
if _, err := m.Get(gardenID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type plantLocationTestModel struct {
|
||||
items map[int]storage.PlantLocation
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (m *plantLocationTestModel) Insert(_ int, value storage.PlantLocation) (storage.PlantLocation, error) {
|
||||
m.nextID++
|
||||
value.ID = m.nextID
|
||||
value.Version = 1
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *plantLocationTestModel) Get(_ int, id int) (storage.PlantLocation, error) {
|
||||
value, ok := m.items[id]
|
||||
if !ok {
|
||||
return storage.PlantLocation{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (m *plantLocationTestModel) GetAllForPlant(_ int, plantID int) ([]storage.PlantLocation, error) {
|
||||
result := []storage.PlantLocation{}
|
||||
for _, value := range m.items {
|
||||
if value.PlantID == plantID {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (m *plantLocationTestModel) GetAllForLocation(_ int, locationID int) ([]storage.PlantLocation, error) {
|
||||
result := []storage.PlantLocation{}
|
||||
for _, value := range m.items {
|
||||
if value.LocationID == locationID {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (m *plantLocationTestModel) Update(_ int, value storage.PlantLocation) (storage.PlantLocation, error) {
|
||||
value.Version++
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *plantLocationTestModel) Delete(_ int, id int) error {
|
||||
if _, ok := m.items[id]; !ok {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLocationsRejectForeignParentsAndCycles(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 7, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
rootID := 1
|
||||
locations := &locationTestModel{items: map[int]storage.Location{1: {ID: 1, GardenID: 3, Name: "Beet", CreatedBy: user.ID, Version: 1}, 2: {ID: 2, GardenID: 3, ParentID: &rootID, Name: "Reihe", CreatedBy: user.ID, Version: 1}, 9: {ID: 9, GardenID: 4, Name: "Fremd", Version: 1}}, nextID: 9}
|
||||
app.models.Locations = locations
|
||||
|
||||
foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/locations", []byte(`{"name":"Topf","parent_id":9}`))
|
||||
if foreign.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("foreign parent: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String())
|
||||
}
|
||||
cycle := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/1", []byte(`{"parent_id":2}`))
|
||||
if cycle.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("cycle: got %d, want %d; %s", cycle.Code, http.StatusUnprocessableEntity, cycle.Body.String())
|
||||
}
|
||||
foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/locations/9", nil)
|
||||
if foreignRead.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign read: got %d, want %d", foreignRead.Code, http.StatusNotFound)
|
||||
}
|
||||
cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/2", []byte(`{"clear_parent_id":true}`))
|
||||
if cleared.Code != http.StatusOK || locations.items[2].ParentID != nil {
|
||||
t.Fatalf("clear parent: status=%d body=%s", cleared.Code, cleared.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlantLocationIsScopedToGarden(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 8, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate", Status: "active"}}}
|
||||
app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}}
|
||||
assignments := &plantLocationTestModel{items: map[int]storage.PlantLocation{}, nextID: 10}
|
||||
app.models.PlantLocations = assignments
|
||||
|
||||
foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":9,"quantity":1}`))
|
||||
if foreign.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("foreign assignment: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String())
|
||||
}
|
||||
created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":6,"quantity":3}`))
|
||||
if created.Code != http.StatusCreated {
|
||||
t.Fatalf("assignment: got %d, want %d; %s", created.Code, http.StatusCreated, created.Body.String())
|
||||
}
|
||||
if got := assignments.items[11].Quantity; got != 3 {
|
||||
t.Errorf("quantity: got %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/tomasen/realip"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func (app *application) recoverPanic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
pv := recover()
|
||||
if pv != nil {
|
||||
w.Header().Set("Connection", "close")
|
||||
app.serverErrorResponse(w, r, fmt.Errorf("%v", pv))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) rateLimit(next http.Handler) http.Handler {
|
||||
if !app.config.Limiter.Enabled {
|
||||
return next
|
||||
}
|
||||
|
||||
type client struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
clients = make(map[string]*client)
|
||||
)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
|
||||
mu.Lock()
|
||||
|
||||
for ip, client := range clients {
|
||||
if time.Since(client.lastSeen) > 3*time.Minute {
|
||||
delete(clients, ip)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := realip.FromRequest(r)
|
||||
|
||||
mu.Lock()
|
||||
|
||||
if _, found := clients[ip]; !found {
|
||||
clients[ip] = &client{
|
||||
limiter: rate.NewLimiter(rate.Limit(app.config.Limiter.Rps), app.config.Limiter.Burst),
|
||||
}
|
||||
}
|
||||
|
||||
clients[ip].lastSeen = time.Now()
|
||||
|
||||
if !clients[ip].limiter.Allow() {
|
||||
mu.Unlock()
|
||||
app.rateLimitExceededResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Vary", "Authorization")
|
||||
|
||||
authorizationHeader := r.Header.Get("Authorization")
|
||||
|
||||
if authorizationHeader != "" {
|
||||
headerParts := strings.Split(authorizationHeader, " ")
|
||||
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
|
||||
app.invalidAuthenticationTokenResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := headerParts[1]
|
||||
v := validate.New()
|
||||
|
||||
if auth.ValidateTokenPlaintext(v, token); !v.Valid() {
|
||||
app.invalidAuthenticationTokenResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetForToken(auth.ScopeAuthentication, token)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.invalidAuthenticationTokenResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
r = app.contextSetAuthenticatedUser(r, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := app.sessions.GetInt(r.Context(), authenticatedUserIDSessionKey)
|
||||
if userID != 0 {
|
||||
user, err := app.models.Users.GetByID(userID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
if err := app.sessions.Destroy(r.Context()); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
r = app.contextSetAuthenticatedUser(r, user)
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !authenticatedUser.Activated {
|
||||
app.inactiveAccountResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
gardenID, err := app.readGardenIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
member, err := app.models.GardenMembers.Get(gardenID, authenticatedUser.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
r = app.contextSetGardenMember(r, member)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requireGardenPermission(permission storage.GardenPermission, next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
member, found := app.contextGetGardenMember(r)
|
||||
if !found {
|
||||
app.serverErrorResponse(w, r, errors.New("garden permission check without membership context"))
|
||||
return
|
||||
}
|
||||
if !member.Can(permission) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authorizeGardenResource checks an object permission after the object was loaded.
|
||||
// Objects created by the caller use the "own" permission; every other object uses
|
||||
// the corresponding "other" permission.
|
||||
func (app *application) authorizeGardenResource(w http.ResponseWriter, r *http.Request, createdBy int, own, other storage.GardenPermission) bool {
|
||||
member, found := app.contextGetGardenMember(r)
|
||||
if !found {
|
||||
app.serverErrorResponse(w, r, errors.New("resource permission check without membership context"))
|
||||
return false
|
||||
}
|
||||
user, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return false
|
||||
}
|
||||
permission := other
|
||||
if createdBy == user.ID {
|
||||
permission = own
|
||||
}
|
||||
if !member.Can(permission) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *application) requireApplicationPermission(permission storage.ApplicationPermission, next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
if !user.Can(permission) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) enableCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Add("Vary", "Access-Control-Request-Method")
|
||||
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if origin != "" {
|
||||
trustedOrigin := false
|
||||
for i := range app.config.Cors.TrustedOrigins {
|
||||
if origin == app.config.Cors.TrustedOrigins[i] {
|
||||
trustedOrigin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !trustedOrigin {
|
||||
app.untrustedOriginResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type metricsResponseWriter struct {
|
||||
wrapped http.ResponseWriter
|
||||
statusCode int
|
||||
headerWritten bool
|
||||
}
|
||||
|
||||
func newMetricsResponseWriter(w http.ResponseWriter) *metricsResponseWriter {
|
||||
return &metricsResponseWriter{
|
||||
wrapped: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
// Header implements http.ResponseWriter.
|
||||
func (mw *metricsResponseWriter) Header() http.Header {
|
||||
return mw.wrapped.Header()
|
||||
}
|
||||
|
||||
// WriteHeader implements http.ResponseWriter while retaining the first status
|
||||
// code for metrics.
|
||||
func (mw *metricsResponseWriter) WriteHeader(statusCode int) {
|
||||
mw.wrapped.WriteHeader(statusCode)
|
||||
|
||||
if !mw.headerWritten {
|
||||
mw.statusCode = statusCode
|
||||
mw.headerWritten = true
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements http.ResponseWriter.
|
||||
func (mw *metricsResponseWriter) Write(b []byte) (int, error) {
|
||||
mw.headerWritten = true
|
||||
return mw.wrapped.Write(b)
|
||||
}
|
||||
|
||||
// Unwrap exposes the underlying writer to net/http response-controller logic.
|
||||
func (mw *metricsResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return mw.wrapped
|
||||
}
|
||||
|
||||
func (app *application) metrics(next http.Handler) http.Handler {
|
||||
var (
|
||||
totalRequestsReceived = expvar.NewInt("total_requests_received")
|
||||
totalResponsesSent = expvar.NewInt("total_responses_sent")
|
||||
totalProcessingTimeMicroseconds = expvar.NewInt("total_processing_time_μs")
|
||||
totalResponsesSentByStatus = expvar.NewMap("total_responses_sent_by_status")
|
||||
)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
totalRequestsReceived.Add(1)
|
||||
|
||||
mw := newMetricsResponseWriter(w)
|
||||
next.ServeHTTP(mw, r)
|
||||
|
||||
totalResponsesSent.Add(1)
|
||||
totalResponsesSentByStatus.Add(strconv.Itoa(mw.statusCode), 1)
|
||||
|
||||
duration := time.Since(start).Microseconds()
|
||||
totalProcessingTimeMicroseconds.Add(duration)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestGardenRolePermissions(t *testing.T) {
|
||||
tests := []struct {
|
||||
role storage.GardenRole
|
||||
permission storage.GardenPermission
|
||||
want int
|
||||
}{
|
||||
{storage.GardenRoleOwner, storage.GardenPermissionGardenDelete, http.StatusNoContent},
|
||||
{storage.GardenRoleAdmin, storage.GardenPermissionMembersWrite, http.StatusNoContent},
|
||||
{storage.GardenRoleAdmin, storage.GardenPermissionGardenDelete, http.StatusForbidden},
|
||||
{storage.GardenRoleMember, storage.GardenPermissionPlantUpdateOwn, http.StatusNoContent},
|
||||
{storage.GardenRoleMember, storage.GardenPermissionSpeciesWrite, http.StatusForbidden},
|
||||
{storage.GardenRoleViewer, storage.GardenPermissionPlantUpdateOwn, http.StatusForbidden},
|
||||
{storage.GardenRoleWorker, storage.GardenPermissionTaskCompleteOther, http.StatusNoContent},
|
||||
{storage.GardenRoleWorker, storage.GardenPermissionTaskUpdateOther, http.StatusForbidden},
|
||||
}
|
||||
app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role)+"/"+string(tt.permission), func(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := app.requireGardenPermission(tt.permission, next)
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != tt.want {
|
||||
t.Fatalf("status = %d, want %d", response.Code, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedGardenAdminWildcardDoesNotGrantOwnershipRights(t *testing.T) {
|
||||
member := storage.GardenMember{Role: storage.GardenRoleAdmin, Permissions: []storage.GardenPermission{"garden:*"}}
|
||||
if member.Can(storage.GardenPermissionGardenDelete) {
|
||||
t.Fatal("garden:* must not grant the owner-only garden:delete permission")
|
||||
}
|
||||
if !member.Can(storage.GardenPermissionMembersWrite) {
|
||||
t.Fatal("garden:* must grant ordinary garden administration permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenPermissionOverridesResolveWildcards(t *testing.T) {
|
||||
permissions := storage.ResolveGardenPermissions([]string{"garden:*"}, []storage.GardenRolePermissionOverride{{Permission: string(storage.GardenPermissionMembersWrite), Granted: false}, {Permission: string(storage.GardenPermissionGardenDelete), Granted: true}})
|
||||
member := storage.GardenMember{Permissions: permissions}
|
||||
if member.Can(storage.GardenPermissionMembersWrite) || !member.Can(storage.GardenPermissionGardenDelete) {
|
||||
t.Fatalf("unexpected effective permissions: %v", permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenRoleBundlesConcretePermissions(t *testing.T) {
|
||||
member := storage.GardenRoleMember.Permissions()
|
||||
found := false
|
||||
for _, permission := range member {
|
||||
if permission == storage.GardenPermissionPlantUpdateOther {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if found {
|
||||
t.Fatal("member role must not bundle permissions for other users' plants")
|
||||
}
|
||||
if len(storage.GardenRoleAdmin.Permissions()) <= len(member) {
|
||||
t.Fatal("admin role must bundle more permissions than member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenResourcePermissionsDistinguishOwnAndOtherObjects(t *testing.T) {
|
||||
app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
tests := []struct {
|
||||
name string
|
||||
role storage.GardenRole
|
||||
createdBy int
|
||||
want int
|
||||
}{
|
||||
{"member may update own plant", storage.GardenRoleMember, 7, http.StatusNoContent},
|
||||
{"member may not update another plant", storage.GardenRoleMember, 8, http.StatusForbidden},
|
||||
{"admin may update another plant", storage.GardenRoleAdmin, 8, http.StatusNoContent},
|
||||
{"viewer may read another task", storage.GardenRoleViewer, 8, http.StatusNoContent},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPatch, "/", nil)
|
||||
request = app.contextSetAuthenticatedUser(request, storage.User{ID: 7})
|
||||
request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role})
|
||||
response := httptest.NewRecorder()
|
||||
own, other := storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther
|
||||
if tt.role == storage.GardenRoleViewer {
|
||||
own, other = storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther
|
||||
}
|
||||
if app.authorizeGardenResource(response, request, tt.createdBy, own, other) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
if response.Code != tt.want {
|
||||
t.Fatalf("status = %d, want %d", response.Code, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationRolePermissions(t *testing.T) {
|
||||
if !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
t.Fatal("admin role must grant global species write permission")
|
||||
}
|
||||
if storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
t.Fatal("user role must not grant global species write permission")
|
||||
}
|
||||
if !storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGardensCreate) || !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGardensCreate) {
|
||||
t.Fatal("built-in application roles must retain permission to create gardens")
|
||||
}
|
||||
if !storage.ApplicationRoleAdmin.Valid() || !storage.ApplicationRoleUser.Valid() || storage.ApplicationRole("unknown").Valid() {
|
||||
t.Fatal("application role validation returned an unexpected result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireApplicationPermission(t *testing.T) {
|
||||
app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, next)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
user storage.User
|
||||
want int
|
||||
}{
|
||||
{"admin role", storage.User{Role: storage.ApplicationRoleAdmin, Permissions: storage.ApplicationRoleAdmin.Permissions()}, http.StatusNoContent},
|
||||
{"user role", storage.User{Role: storage.ApplicationRoleUser, Permissions: storage.ApplicationRoleUser.Permissions()}, http.StatusForbidden},
|
||||
{"custom server role", storage.User{Role: "application:user-manager", Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionUsersManage}}, http.StatusNoContent},
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/admin/users", nil)
|
||||
request = app.contextSetAuthenticatedUser(request, test.user)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != test.want {
|
||||
t.Errorf("%s: got %d, want %d", test.name, response.Code, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type plantLocationInput struct {
|
||||
LocationID *int `json:"location_id"`
|
||||
Quantity *int `json:"quantity"`
|
||||
PlantedAt *time.Time `json:"planted_at"`
|
||||
ClearPlantedAt bool `json:"clear_planted_at"`
|
||||
RemovedAt *time.Time `json:"removed_at"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
func (input plantLocationInput) apply(assignment *storage.PlantLocation) {
|
||||
if input.LocationID != nil {
|
||||
assignment.LocationID = *input.LocationID
|
||||
}
|
||||
if input.Quantity != nil {
|
||||
assignment.Quantity = *input.Quantity
|
||||
}
|
||||
if input.PlantedAt != nil {
|
||||
assignment.PlantedAt = input.PlantedAt
|
||||
}
|
||||
if input.ClearPlantedAt {
|
||||
assignment.PlantedAt = nil
|
||||
}
|
||||
if input.RemovedAt != nil {
|
||||
assignment.RemovedAt = input.RemovedAt
|
||||
}
|
||||
if input.Notes != nil {
|
||||
assignment.Notes = strings.TrimSpace(*input.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createPlantLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := app.models.Plants.Get(gardenID, plantID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input plantLocationInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
assignment := storage.PlantLocation{PlantID: plantID, Quantity: 1}
|
||||
input.apply(&assignment)
|
||||
if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) {
|
||||
return
|
||||
}
|
||||
assignment, err = app.models.PlantLocations.Insert(gardenID, assignment)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d/locations/%d", gardenID, plantID, assignment.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"plant_location": assignment}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listPlantLocationsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := app.models.Plants.Get(gardenID, plantID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
assignments, err := app.models.PlantLocations.GetAllForPlant(gardenID, plantID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"plant_locations": assignments}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updatePlantLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, assignmentID, ok := app.readPlantLocationIDs(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID)
|
||||
if err != nil || assignment.PlantID != plantID {
|
||||
if err == nil {
|
||||
err = storage.ErrRecordNotFound
|
||||
}
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input plantLocationInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&assignment)
|
||||
if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) {
|
||||
return
|
||||
}
|
||||
assignment, err = app.models.PlantLocations.Update(gardenID, assignment)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"plant_location": assignment}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deletePlantLocationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, assignmentID, ok := app.readPlantLocationIDs(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID)
|
||||
if err != nil || assignment.PlantID != plantID {
|
||||
if err == nil {
|
||||
err = storage.ErrRecordNotFound
|
||||
}
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.models.PlantLocations.Delete(gardenID, assignmentID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) readPlantLocationIDs(w http.ResponseWriter, r *http.Request) (int, int, bool) {
|
||||
plantID, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return 0, 0, false
|
||||
}
|
||||
id, err := app.readPathInt(r, "assignmentID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return 0, 0, false
|
||||
}
|
||||
return plantID, id, true
|
||||
}
|
||||
|
||||
func (app *application) readPathInt(r *http.Request, name string) (int, error) {
|
||||
value := httprouter.ParamsFromContext(r.Context()).ByName(name)
|
||||
id, err := strconv.Atoi(value)
|
||||
if err != nil || id < 1 {
|
||||
return 0, errors.New("invalid path parameter")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (app *application) validatePlantLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, assignment storage.PlantLocation) bool {
|
||||
v := validate.New()
|
||||
storage.ValidatePlantLocation(v, assignment)
|
||||
if assignment.LocationID > 0 {
|
||||
if _, err := app.models.Locations.Get(gardenID, assignment.LocationID); err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
v.AddError("location_id", "must refer to a location in this garden")
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type plantInput struct {
|
||||
Tags []string `json:"tags"`
|
||||
SpeciesID *int `json:"species_id"`
|
||||
ClearSpeciesID bool `json:"clear_species_id"`
|
||||
Name *string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
ImageData *string `json:"image_data"`
|
||||
ImageID *int `json:"image_id"`
|
||||
AcquiredAt *time.Time `json:"acquired_at"`
|
||||
ClearAcquiredAt bool `json:"clear_acquired_at"`
|
||||
Status *string `json:"status"`
|
||||
RemovedAt *time.Time `json:"removed_at"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
}
|
||||
|
||||
func (input plantInput) apply(plant *storage.Plant) {
|
||||
if input.SpeciesID != nil {
|
||||
plant.SpeciesID = input.SpeciesID
|
||||
}
|
||||
if input.ClearSpeciesID {
|
||||
plant.SpeciesID = nil
|
||||
}
|
||||
if input.Name != nil {
|
||||
plant.Name = strings.TrimSpace(*input.Name)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
plant.Notes = strings.TrimSpace(*input.Notes)
|
||||
}
|
||||
if input.ImageData != nil {
|
||||
plant.ImageData = *input.ImageData
|
||||
}
|
||||
if input.AcquiredAt != nil {
|
||||
plant.AcquiredAt = input.AcquiredAt
|
||||
}
|
||||
if input.ClearAcquiredAt {
|
||||
plant.AcquiredAt = nil
|
||||
}
|
||||
if input.Status != nil {
|
||||
plant.Status = *input.Status
|
||||
}
|
||||
if input.RemovedAt != nil {
|
||||
plant.RemovedAt = input.RemovedAt
|
||||
}
|
||||
if len(input.Attributes) != 0 {
|
||||
plant.Attributes = input.Attributes
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createPlantHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
var input plantInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
plant := storage.Plant{GardenID: gardenID, Status: "alive", Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID, PlantedBy: user.ID}
|
||||
input.apply(&plant)
|
||||
plant.Tags = storage.NormalizeTags(input.Tags)
|
||||
if !app.validatePlantForGarden(w, r, gardenID, plant) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
plant.ImageID, err = app.resolveImage(gardenID, plant.ImageData, input.ImageID, user.ID, "plant")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
plant.ImageData = ""
|
||||
plant, err = app.models.Plants.Insert(plant)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d", gardenID, plant.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"plant": plant}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listPlantsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plants, err := app.models.Plants.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
visible := plants[:0]
|
||||
for i := range plants {
|
||||
if plants[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionPlantReadOther) {
|
||||
continue
|
||||
}
|
||||
if plants[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionPlantReadOwn) {
|
||||
continue
|
||||
}
|
||||
plants[i].Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plants[i].ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
visible = append(visible, plants[i])
|
||||
}
|
||||
plants = visible
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"plants": plants}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showPlantHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
plant, err := app.models.Plants.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantReadOwn, storage.GardenPermissionPlantReadOther) {
|
||||
return
|
||||
}
|
||||
plant.Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plant.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updatePlantHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
plant, err := app.models.Plants.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther) {
|
||||
return
|
||||
}
|
||||
var input plantInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
previousImageID := plant.ImageID
|
||||
previousImageData := plant.ImageData
|
||||
input.apply(&plant)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
plant.UpdatedBy = user.ID
|
||||
if input.Tags != nil {
|
||||
plant.Tags = storage.NormalizeTags(input.Tags)
|
||||
}
|
||||
if !app.validatePlantForGarden(w, r, gardenID, plant) {
|
||||
return
|
||||
}
|
||||
if input.ImageData != nil && *input.ImageData != previousImageData {
|
||||
plant.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "plant")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
plant.ImageData = ""
|
||||
plant, err = app.models.Plants.Update(gardenID, plant)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "plant", EntityID: plant.ID, PreviousImageID: previousImageID, ImageID: plant.ImageID, ChangedBy: user.ID}); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Tags != nil {
|
||||
plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deletePlantHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
plant, err := app.models.Plants.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantDeleteOwn, storage.GardenPermissionPlantDeleteOther) {
|
||||
return
|
||||
}
|
||||
if err := app.models.Plants.Delete(gardenID, id); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) validatePlantForGarden(w http.ResponseWriter, r *http.Request, gardenID int, plant storage.Plant) bool {
|
||||
v := validate.New()
|
||||
validateImageData(v, plant.ImageData)
|
||||
storage.ValidatePlant(v, plant)
|
||||
if plant.SpeciesID != nil && *plant.SpeciesID > 0 {
|
||||
if _, err := app.models.Species.Get(gardenID, *plant.SpeciesID); err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
v.AddError("species_id", "must refer to an available species")
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type speciesTestModel struct {
|
||||
items map[int]storage.Species
|
||||
nextID int
|
||||
lastGardenID int
|
||||
}
|
||||
|
||||
func (m *speciesTestModel) Insert(species storage.Species) (storage.Species, error) {
|
||||
m.nextID++
|
||||
species.ID = m.nextID
|
||||
species.Version = 1
|
||||
m.items[species.ID] = species
|
||||
return species, nil
|
||||
}
|
||||
|
||||
func (m *speciesTestModel) Get(gardenID, id int) (storage.Species, error) {
|
||||
m.lastGardenID = gardenID
|
||||
species, ok := m.items[id]
|
||||
if !ok || (species.GardenID != nil && *species.GardenID != gardenID) {
|
||||
return storage.Species{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return species, nil
|
||||
}
|
||||
|
||||
func (m *speciesTestModel) GetAllForGarden(gardenID int) ([]storage.Species, error) {
|
||||
m.lastGardenID = gardenID
|
||||
result := []storage.Species{}
|
||||
for _, species := range m.items {
|
||||
if species.GardenID == nil || *species.GardenID == gardenID {
|
||||
result = append(result, species)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *speciesTestModel) Update(gardenID int, species storage.Species) (storage.Species, error) {
|
||||
m.lastGardenID = gardenID
|
||||
species.Version++
|
||||
m.items[species.ID] = species
|
||||
return species, nil
|
||||
}
|
||||
|
||||
func (m *speciesTestModel) Delete(gardenID, id int) error {
|
||||
species, ok := m.items[id]
|
||||
if !ok || species.GardenID == nil && gardenID != 0 || species.GardenID != nil && *species.GardenID != gardenID {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
m.lastGardenID = gardenID
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type plantTestModel struct {
|
||||
items map[int]storage.Plant
|
||||
nextID int
|
||||
lastGardenID int
|
||||
}
|
||||
|
||||
func (m *plantTestModel) Insert(plant storage.Plant) (storage.Plant, error) {
|
||||
m.nextID++
|
||||
plant.ID = m.nextID
|
||||
plant.Version = 1
|
||||
m.items[plant.ID] = plant
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
func (m *plantTestModel) Get(gardenID, id int) (storage.Plant, error) {
|
||||
m.lastGardenID = gardenID
|
||||
plant, ok := m.items[id]
|
||||
if !ok || plant.GardenID != gardenID {
|
||||
return storage.Plant{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
func (m *plantTestModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) {
|
||||
m.lastGardenID = gardenID
|
||||
result := []storage.Plant{}
|
||||
for _, plant := range m.items {
|
||||
if plant.GardenID == gardenID {
|
||||
result = append(result, plant)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *plantTestModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) {
|
||||
m.lastGardenID = gardenID
|
||||
plant.Version++
|
||||
m.items[plant.ID] = plant
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
func (m *plantTestModel) Delete(gardenID, id int) error {
|
||||
plant, ok := m.items[id]
|
||||
if !ok || plant.GardenID != gardenID {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveResourceRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
router := httprouter.New()
|
||||
protect := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return app.requireActivatedUser(app.requireGardenMember(handler))
|
||||
}
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", protect(app.createSpeciesHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", protect(app.listSpeciesHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", protect(app.showSpeciesHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", protect(app.updateSpeciesHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", protect(app.deleteSpeciesHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.createSpeciesTaskTemplateHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.listSpeciesTaskTemplatesHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.showSpeciesTaskTemplateHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.updateSpeciesTaskTemplateHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.deleteSpeciesTaskTemplateHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", protect(app.createPlantHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", protect(app.listPlantsHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", protect(app.showPlantHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", protect(app.updatePlantHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", protect(app.deletePlantHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", protect(app.createLocationHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", protect(app.listLocationsHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", protect(app.showLocationHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", protect(app.updateLocationHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", protect(app.deleteLocationHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", protect(app.createTaskHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", protect(app.listTasksHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", protect(app.showTaskHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", protect(app.updateTaskHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", protect(app.deleteTaskHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.createPlantLocationHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.listPlantLocationsHandler))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.updatePlantLocationHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.deletePlantLocationHandler))
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
router.ServeHTTP(w, app.contextSetAuthenticatedUser(r, user))
|
||||
})
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestSpeciesAndPlantsAreScopedToGarden(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 5, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin}
|
||||
speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10}
|
||||
plantsModel := &plantTestModel{items: make(map[int]storage.Plant), nextID: 20}
|
||||
app.models.Species = speciesModel
|
||||
app.models.Plants = plantsModel
|
||||
|
||||
speciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate"}`))
|
||||
if speciesResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("create species status: got %d, want %d; body: %s", speciesResponse.Code, http.StatusCreated, speciesResponse.Body.String())
|
||||
}
|
||||
createdSpecies := speciesModel.items[11]
|
||||
if createdSpecies.GardenID == nil || *createdSpecies.GardenID != 3 {
|
||||
t.Errorf("species garden: got %v, want 3", createdSpecies.GardenID)
|
||||
}
|
||||
|
||||
plantResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":11,"name":"Tomate am Zaun"}`))
|
||||
if plantResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("create plant status: got %d, want %d; body: %s", plantResponse.Code, http.StatusCreated, plantResponse.Body.String())
|
||||
}
|
||||
if got := plantsModel.items[21].GardenID; got != 3 {
|
||||
t.Errorf("plant garden: got %d, want 3", got)
|
||||
}
|
||||
if speciesModel.lastGardenID != 3 {
|
||||
t.Errorf("species lookup garden: got %d, want 3", speciesModel.lastGardenID)
|
||||
}
|
||||
clearSpecies := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/plants/21", []byte(`{"clear_species_id":true,"clear_acquired_at":true}`))
|
||||
if clearSpecies.Code != http.StatusOK || plantsModel.items[21].SpeciesID != nil {
|
||||
t.Fatalf("clear plant species: status=%d body=%s", clearSpecies.Code, clearSpecies.Body.String())
|
||||
}
|
||||
|
||||
foreignGardenID := 4
|
||||
speciesModel.items[99] = storage.Species{ID: 99, GardenID: &foreignGardenID, CommonName: "Fremd"}
|
||||
foreignSpeciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":99,"name":"Nicht erlaubt"}`))
|
||||
if foreignSpeciesResponse.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("foreign species status: got %d, want %d; body: %s", foreignSpeciesResponse.Code, http.StatusUnprocessableEntity, foreignSpeciesResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalSpeciesRequiresApplicationPermission(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 6, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
speciesModel := &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global", Version: 1}}}
|
||||
app.models.Species = speciesModel
|
||||
|
||||
showResponse := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/1", nil)
|
||||
if showResponse.Code != http.StatusOK {
|
||||
t.Fatalf("show global species: got %d, want %d", showResponse.Code, http.StatusOK)
|
||||
}
|
||||
updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/1", []byte(`{"common_name":"Geändert"}`))
|
||||
if updateResponse.Code != http.StatusForbidden {
|
||||
t.Fatalf("update global species: got %d, want %d", updateResponse.Code, http.StatusForbidden)
|
||||
}
|
||||
deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/1", nil)
|
||||
if deleteResponse.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete global species: got %d, want %d", deleteResponse.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCreateAndUpdateGlobalSpeciesWithoutGardenWriteRole(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 7, Activated: true, Role: storage.ApplicationRoleAdmin}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleViewer}
|
||||
speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10}
|
||||
app.models.Species = speciesModel
|
||||
|
||||
createResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate","global":true}`))
|
||||
if createResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("create global species: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String())
|
||||
}
|
||||
if speciesModel.items[11].GardenID != nil {
|
||||
t.Fatalf("global species has garden id %v", speciesModel.items[11].GardenID)
|
||||
}
|
||||
|
||||
updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/11", []byte(`{"common_name":"Rispentomate"}`))
|
||||
if updateResponse.Code != http.StatusOK {
|
||||
t.Fatalf("update global species: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String())
|
||||
}
|
||||
if got := speciesModel.lastGardenID; got != 0 {
|
||||
t.Fatalf("global update scope: got %d, want 0", got)
|
||||
}
|
||||
|
||||
deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/11", nil)
|
||||
if deleteResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete global species: got %d, want %d; body: %s", deleteResponse.Code, http.StatusNoContent, deleteResponse.Body.String())
|
||||
}
|
||||
if got := speciesModel.lastGardenID; got != 0 {
|
||||
t.Fatalf("global delete scope: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
var roleNameRX = regexp.MustCompile(`^[a-z][a-z0-9:_-]{1,63}$`)
|
||||
|
||||
func validRolePermissions(scope storage.RoleScope, permissions []string) bool {
|
||||
for _, permission := range permissions {
|
||||
if scope == storage.RoleScopeApplication && !storage.ValidApplicationPermission(permission) ||
|
||||
scope == storage.RoleScopeGarden && !storage.ValidGardenPermission(permission) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return scope == storage.RoleScopeApplication || scope == storage.RoleScopeGarden
|
||||
}
|
||||
|
||||
func (app *application) listAdminRolesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
applicationRoles, err := app.models.Roles.List(storage.RoleScopeApplication)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
gardenRoles, err := app.models.Roles.List(storage.RoleScopeGarden)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
app.writeJSON(w, http.StatusOK, envelope{"application_roles": applicationRoles, "garden_roles": gardenRoles}, nil)
|
||||
}
|
||||
|
||||
func (app *application) createAdminRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Scope storage.RoleScope `json:"scope"`
|
||||
Label string `json:"label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Name, input.Label = strings.TrimSpace(input.Name), strings.TrimSpace(input.Label)
|
||||
if input.Scope == storage.RoleScopeApplication && !strings.HasPrefix(input.Name, "application:") {
|
||||
input.Name = "application:" + input.Name
|
||||
}
|
||||
if !roleNameRX.MatchString(input.Name) || input.Label == "" || !validRolePermissions(input.Scope, input.Permissions) {
|
||||
app.failedValidationResponse(w, r, map[string]string{"role": "name, scope, label, or permissions are invalid"})
|
||||
return
|
||||
}
|
||||
role, err := app.models.Roles.Create(storage.Role{Name: input.Name, Scope: input.Scope, Label: input.Label, Permissions: input.Permissions})
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
app.conflictResponse(w, r)
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil)
|
||||
}
|
||||
|
||||
func (app *application) updateAdminRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := httprouter.ParamsFromContext(r.Context()).ByName("roleName")
|
||||
role, err := app.models.Roles.Get(name)
|
||||
if err != nil || role.GardenID != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Label string `json:"label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err = app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.Label = strings.TrimSpace(input.Label)
|
||||
if input.Label == "" || !validRolePermissions(role.Scope, input.Permissions) {
|
||||
app.failedValidationResponse(w, r, map[string]string{"role": "label or permissions are invalid"})
|
||||
return
|
||||
}
|
||||
if role.Name == string(storage.GardenRoleOwner) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if role.Name == string(storage.ApplicationRoleAdmin) {
|
||||
hasRoleManagement := false
|
||||
for _, permission := range input.Permissions {
|
||||
if permission == string(storage.ApplicationPermissionRolesManage) || permission == "*" {
|
||||
hasRoleManagement = true
|
||||
}
|
||||
}
|
||||
if !hasRoleManagement {
|
||||
app.failedValidationResponse(w, r, map[string]string{"permissions": "the administrator role must retain roles:manage"})
|
||||
return
|
||||
}
|
||||
}
|
||||
role.Label, role.Permissions = input.Label, input.Permissions
|
||||
role, err = app.models.Roles.Update(role)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
app.writeJSON(w, http.StatusOK, envelope{"role": role}, nil)
|
||||
}
|
||||
|
||||
func (app *application) deleteAdminRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := httprouter.ParamsFromContext(r.Context()).ByName("roleName")
|
||||
role, err := app.models.Roles.Get(name)
|
||||
if err != nil || role.GardenID != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err := app.models.Roles.Delete(name); err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
app.conflictResponse(w, r)
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type gardenRoleSetting struct {
|
||||
Role storage.Role `json:"role"`
|
||||
EffectivePermissions []storage.GardenPermission `json:"effective_permissions"`
|
||||
}
|
||||
|
||||
func (app *application) listGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
roles, err := app.models.Roles.ListForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
overrides, err := app.models.Roles.ListGardenOverrides(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
settings := make([]gardenRoleSetting, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
selected := []storage.GardenRolePermissionOverride{}
|
||||
for _, override := range overrides {
|
||||
if override.RoleName == role.Name {
|
||||
selected = append(selected, override)
|
||||
}
|
||||
}
|
||||
settings = append(settings, gardenRoleSetting{Role: role, EffectivePermissions: storage.ResolveGardenPermissions(role.Permissions, selected)})
|
||||
}
|
||||
app.writeJSON(w, http.StatusOK, envelope{"roles": settings}, nil)
|
||||
}
|
||||
|
||||
func (app *application) updateGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
name := httprouter.ParamsFromContext(r.Context()).ByName("roleName")
|
||||
role, err := app.models.Roles.GetForGarden(gardenID, name)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if name == string(storage.GardenRoleOwner) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err = app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !validRolePermissions(storage.RoleScopeGarden, input.Permissions) {
|
||||
app.failedValidationResponse(w, r, map[string]string{"permissions": "contains an invalid garden permission"})
|
||||
return
|
||||
}
|
||||
desired := map[string]bool{}
|
||||
for _, permission := range storage.ResolveGardenPermissions(input.Permissions, nil) {
|
||||
desired[string(permission)] = true
|
||||
}
|
||||
base := storage.ResolveGardenPermissions(role.Permissions, nil)
|
||||
baseSet := map[string]bool{}
|
||||
for _, permission := range base {
|
||||
baseSet[string(permission)] = true
|
||||
}
|
||||
overrides := []storage.GardenRolePermissionOverride{}
|
||||
for _, permission := range storage.AllGardenPermissions() {
|
||||
want, has := desired[string(permission)], baseSet[string(permission)]
|
||||
if want != has {
|
||||
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: gardenID, RoleName: name, Permission: string(permission), Granted: want})
|
||||
}
|
||||
}
|
||||
if err = app.models.Roles.ReplaceGardenOverrides(gardenID, name, overrides); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
app.writeJSON(w, http.StatusOK, envelope{"effective_permissions": storage.ResolveGardenPermissions(role.Permissions, overrides)}, nil)
|
||||
}
|
||||
|
||||
func (app *application) createGardenRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
slug, label := strings.TrimSpace(input.Name), strings.TrimSpace(input.Label)
|
||||
name := "garden:" + strconv.Itoa(gardenID) + ":" + slug
|
||||
if !roleNameRX.MatchString(name) || label == "" || !validRolePermissions(storage.RoleScopeGarden, input.Permissions) {
|
||||
app.failedValidationResponse(w, r, map[string]string{"role": "name, label, or permissions are invalid"})
|
||||
return
|
||||
}
|
||||
role, err := app.models.Roles.Create(storage.Role{Name: name, Scope: storage.RoleScopeGarden, GardenID: &gardenID, Label: label, Permissions: input.Permissions})
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
app.conflictResponse(w, r)
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil)
|
||||
}
|
||||
|
||||
func (app *application) deleteGardenRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
name := httprouter.ParamsFromContext(r.Context()).ByName("roleName")
|
||||
role, err := app.models.Roles.GetForGarden(gardenID, name)
|
||||
if err != nil || role.GardenID == nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.Roles.Delete(name); err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
app.conflictResponse(w, r)
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"net/http"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
router := httprouter.New()
|
||||
|
||||
router.NotFound = http.HandlerFunc(app.notFoundResponse)
|
||||
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
|
||||
|
||||
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
||||
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
|
||||
router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler))
|
||||
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.listAdminUsersHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.deleteAdminUserHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminApplicationSettingsHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminApplicationSettingsHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/environment", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminEnvironmentHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/test-mail", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.sendAdminTestMailHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.listAdminSpeciesCategoriesHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.createAdminSpeciesCategoryHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.updateAdminSpeciesCategoryHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.deleteAdminSpeciesCategoryHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.listAdminTaskPrioritiesHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.createAdminTaskPriorityHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminTaskPriorityHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.deleteAdminTaskPriorityHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.listAdminRolesHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.createAdminRoleHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.updateAdminRoleHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.deleteAdminRoleHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/species-categories", app.requireActivatedUser(app.listSpeciesCategoriesHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/task-priorities", app.requireActivatedUser(app.listTaskPrioritiesHandler))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler)
|
||||
router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler)
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler)
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
|
||||
router.HandlerFunc(http.MethodPost, "/v1/tokens/activation", app.createActivationTokenHandler)
|
||||
router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler)
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenUpdate, app.updateGardenHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/members", app.requireActivatedUser(app.requireGardenMember(app.listGardenMembersHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.updateGardenMemberHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenMemberHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/members/:userID/transfer-ownership", app.protectGarden(storage.GardenPermissionGardenDelete, app.transferGardenOwnershipHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenInvitesHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.createGardenInviteHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/invites/:inviteID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenInviteHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenRoleSettingsHandler))
|
||||
router.HandlerFunc(http.MethodPut, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.updateGardenRoleSettingsHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionGardenDelete, app.createGardenRoleHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenRoleHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/invites/:token/accept", app.requireActivatedUser(app.acceptGardenInviteHandler))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.listCareInstructionsHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.createCareInstructionHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.updateCareInstructionHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.deleteCareInstructionHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesTaskTemplateHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesTaskTemplatesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesTaskTemplateHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesTaskTemplateHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesTaskTemplateHandler)))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", app.protectGarden(storage.GardenPermissionPlantCreate, app.createPlantHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", app.requireActivatedUser(app.requireGardenMember(app.listPlantsHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.showPlantHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.updatePlantHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.deletePlantHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs", app.requireActivatedUser(app.requireGardenMember(app.listTaskTemplateOptOutsHandler)))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.setTaskTemplateOptOutHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteTaskTemplateOptOutHandler))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", app.protectGarden(storage.GardenPermissionLocationCreate, app.createLocationHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", app.requireActivatedUser(app.requireGardenMember(app.listLocationsHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.showLocationHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.updateLocationHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteLocationHandler)))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", app.protectGarden(storage.GardenPermissionTaskCreate, app.createTaskHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", app.requireActivatedUser(app.requireGardenMember(app.listTasksHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.showTaskHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.updateTaskHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteTaskHandler)))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalEntryHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", app.requireActivatedUser(app.requireGardenMember(app.listJournalEntriesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tags", app.requireActivatedUser(app.requireGardenMember(app.listGardenTagsHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images", app.requireActivatedUser(app.requireGardenMember(app.listImagesHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images/:imageID", app.requireActivatedUser(app.requireGardenMember(app.showImageHandler)))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id", app.requireActivatedUser(app.requireGardenMember(app.showJournalEntryHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.updateJournalEntryHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalEntryHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalAttachmentHandler))
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments/library", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalLibraryAttachmentHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.requireActivatedUser(app.requireGardenMember(app.showJournalAttachmentHandler)))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalAttachmentHandler))
|
||||
|
||||
router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", app.protectGarden(storage.GardenPermissionContentWrite, app.createPlantLocationHandler))
|
||||
router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", app.requireActivatedUser(app.requireGardenMember(app.listPlantLocationsHandler)))
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.updatePlantLocationHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deletePlantLocationHandler))
|
||||
|
||||
if app.config.Env == "development" {
|
||||
router.HandlerFunc(http.MethodGet, "/debug/vars", app.requireActivatedUser(expvar.Handler().ServeHTTP))
|
||||
}
|
||||
|
||||
return app.sessions.LoadAndSave(app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router))))))
|
||||
}
|
||||
|
||||
func (app *application) protectGarden(permission storage.GardenPermission, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return app.requireActivatedUser(app.requireGardenMember(app.requireGardenPermission(permission, handler)))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type seasonTemplateDefinition struct {
|
||||
origin storage.TaskTemplateOrigin
|
||||
title string
|
||||
trigger storage.TaskTriggerType
|
||||
monthFrom, dayFrom *int
|
||||
monthTo, dayTo *int
|
||||
}
|
||||
|
||||
func (app *application) syncSeasonTaskTemplates(gardenID int, species storage.Species) error {
|
||||
if app.models.SpeciesTaskTemplates == nil {
|
||||
return nil
|
||||
}
|
||||
existing, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, species.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byOrigin := make(map[storage.TaskTemplateOrigin]storage.SpeciesTaskTemplate, len(existing))
|
||||
for _, template := range existing {
|
||||
if template.Origin != storage.TaskTemplateOriginManual {
|
||||
byOrigin[template.Origin] = template
|
||||
}
|
||||
}
|
||||
definitions := []seasonTemplateDefinition{
|
||||
{storage.TaskTemplateOriginSeasonSowing, "Aussaat", storage.TaskTriggerRelativeToSowing, species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo},
|
||||
{storage.TaskTemplateOriginSeasonPlanting, "Pflanzen", storage.TaskTriggerRelativeToSpeciesPlanting, species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo},
|
||||
{storage.TaskTemplateOriginSeasonHarvest, "Ernten", storage.TaskTriggerRelativeToHarvest, species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo},
|
||||
}
|
||||
writeScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
writeScope = 0
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
template, found := byOrigin[definition.origin]
|
||||
if definition.monthFrom == nil {
|
||||
if found && template.Active {
|
||||
template.Active = false
|
||||
if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
// Preserve all user-editable fields. Only repair legacy generated templates.
|
||||
if template.TriggerType != definition.trigger {
|
||||
template.TriggerType = definition.trigger
|
||||
if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
duration := seasonDurationDays(definition.monthFrom, definition.dayFrom, definition.monthTo, definition.dayTo)
|
||||
template = storage.SpeciesTaskTemplate{
|
||||
SpeciesID: species.ID, Origin: definition.origin, Title: definition.title,
|
||||
Description: definition.title + " im vorgesehenen Zeitraum", TriggerType: definition.trigger,
|
||||
TriggerOffsetUnit: storage.TaskDurationDay, Duration: duration, DurationUnit: storage.TaskDurationDay,
|
||||
Recurrence: storage.TaskRecurrenceNone, RecurrenceInterval: 1, Active: true,
|
||||
}
|
||||
if _, err = app.models.SpeciesTaskTemplates.Insert(template); err != nil && !errors.Is(err, storage.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seasonDurationDays(fromMonth, fromDay, toMonth, toDay *int) int {
|
||||
if fromMonth == nil || toMonth == nil {
|
||||
return 0
|
||||
}
|
||||
startDay, endDay := 1, 1
|
||||
if fromDay != nil {
|
||||
startDay = *fromDay
|
||||
}
|
||||
if toDay != nil {
|
||||
endDay = *toDay
|
||||
}
|
||||
start := time.Date(2024, time.Month(*fromMonth), startDay, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, time.Month(*toMonth), endDay, 0, 0, 0, 0, time.UTC)
|
||||
if end.Before(start) {
|
||||
end = end.AddDate(1, 0, 0)
|
||||
}
|
||||
return int(end.Sub(start).Hours() / 24)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestSyncSeasonTaskTemplatesCreatesAndPreservesDerivedTemplates(t *testing.T) {
|
||||
gardenID, march, day, april := 3, 3, 10, 4
|
||||
model := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 4}
|
||||
app, _, _ := newGardenTestApplication()
|
||||
app.models.SpeciesTaskTemplates = model
|
||||
species := storage.Species{ID: 2, GardenID: &gardenID, PlantingMonthFrom: &march, PlantingDayFrom: &day, PlantingMonthTo: &april, PlantingDayTo: &day}
|
||||
|
||||
if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(model.items) != 1 {
|
||||
t.Fatalf("templates=%d want=1", len(model.items))
|
||||
}
|
||||
var generated storage.SpeciesTaskTemplate
|
||||
for _, generated = range model.items {
|
||||
}
|
||||
if generated.Origin != storage.TaskTemplateOriginSeasonPlanting || generated.TriggerType != storage.TaskTriggerRelativeToSpeciesPlanting {
|
||||
t.Fatalf("unexpected generated template: %+v", generated)
|
||||
}
|
||||
if generated.Duration != 31 || generated.Recurrence != storage.TaskRecurrenceNone {
|
||||
t.Fatalf("unexpected generated schedule: %+v", generated)
|
||||
}
|
||||
|
||||
generated.Title, generated.Active = "Eigener Titel", false
|
||||
model.items[generated.ID] = generated
|
||||
if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := model.items[generated.ID]; got.Title != "Eigener Titel" || got.Active {
|
||||
t.Fatalf("user changes were overwritten: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedTaskUsesSpeciesPlantingSeason(t *testing.T) {
|
||||
acquired := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
month, day := 3, 15
|
||||
plant := storage.Plant{ID: 8, AcquiredAt: &acquired}
|
||||
species := storage.Species{PlantingMonthFrom: &month, PlantingDayFrom: &day}
|
||||
template := storage.SpeciesTaskTemplate{ID: 9, Title: "Pflanzen", TriggerType: storage.TaskTriggerRelativeToSpeciesPlanting, DurationUnit: storage.TaskDurationDay}
|
||||
tasks := generatedTasksForTemplate(plant, species, template, nil, 3, 1, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
if len(tasks) != 1 || tasks[0].DueAtStart.Month() != time.March || tasks[0].DueAtStart.Day() != 15 || tasks[0].DueAtStart.Year() != 2027 {
|
||||
t.Fatalf("unexpected planting season task: %+v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (app *application) serve() error {
|
||||
maintenanceContext, stopMaintenance := context.WithCancel(context.Background())
|
||||
defer stopMaintenance()
|
||||
app.background(func() {
|
||||
if err := app.runLifecycleMaintenance(time.Now()); err != nil {
|
||||
app.logger.Error("lifecycle maintenance failed", "error", err)
|
||||
}
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-maintenanceContext.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
if err := app.runLifecycleMaintenance(now); err != nil {
|
||||
app.logger.Error("lifecycle maintenance failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := &http.Server{
|
||||
Addr: net.JoinHostPort(app.config.Host, strconv.Itoa(app.config.Port)),
|
||||
Handler: app.routes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
shutdownError := make(chan error)
|
||||
|
||||
go func() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
s := <-quit
|
||||
|
||||
app.logger.Info("stopping server", "addr", srv.Addr, "signal", s.String())
|
||||
shutdownError <- srv.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.Env)
|
||||
err := srv.ListenAndServe()
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = <-shutdownError
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopMaintenance()
|
||||
app.logger.Info("waiting for background tasks")
|
||||
app.wg.Wait()
|
||||
|
||||
app.logger.Info("shutdown complete")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
const authenticatedUserIDSessionKey = "authenticatedUserID"
|
||||
|
||||
const (
|
||||
accountSessionIDKey = "accountSessionID"
|
||||
accountSessionCreatedAtKey = "accountSessionCreatedAt"
|
||||
)
|
||||
|
||||
func (app *application) createSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
storage.ValidateEmail(v, input.Email)
|
||||
auth.ValidatePasswordPlaintext(v, input.Password)
|
||||
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
match, err := user.Password.Matches(input.Password)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !match {
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.sessions.RenewToken(r.Context()); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.sessions.Put(r.Context(), authenticatedUserIDSessionKey, user.ID)
|
||||
app.sessions.Put(r.Context(), accountSessionIDKey, rand.Text())
|
||||
app.sessions.Put(r.Context(), accountSessionCreatedAtKey, time.Now().UTC().Unix())
|
||||
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, found := app.contextGetAuthenticatedUser(r)
|
||||
if !found {
|
||||
app.authenticationRequiredResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := app.sessions.Destroy(r.Context()); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type sessionTestUserModel struct {
|
||||
user storage.User
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) Insert(user storage.User) (storage.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) GetByID(id int) (storage.User, error) {
|
||||
if id != m.user.ID {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return m.user, nil
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) GetByEmail(email string) (storage.User, error) {
|
||||
if email != m.user.Email {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return m.user, nil
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) Update(user storage.User) (storage.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) GetForToken(string, string) (storage.User, error) {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) CreateEmailChange(int, string, time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (m sessionTestUserModel) ConfirmEmailChange(string, int) (storage.User, error) {
|
||||
return storage.User{}, nil
|
||||
}
|
||||
func (m sessionTestUserModel) GetAll() ([]storage.User, error) {
|
||||
return []storage.User{m.user}, nil
|
||||
}
|
||||
func (m sessionTestUserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) {
|
||||
user := m.user
|
||||
user.Role = role
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m sessionTestUserModel) Delete(int) error { return nil }
|
||||
|
||||
func newSessionTestApplication(t *testing.T) (*application, http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user := storage.User{
|
||||
ID: 42,
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Password: *auth.NewPassword(passwordHash),
|
||||
Activated: true,
|
||||
}
|
||||
|
||||
sessions := scs.New()
|
||||
sessions.Cookie.Name = "gardomatic_session"
|
||||
|
||||
app := &application{
|
||||
config: Config{
|
||||
Cors: struct{ TrustedOrigins []string }{
|
||||
TrustedOrigins: []string{"http://localhost:8080"},
|
||||
},
|
||||
},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
models: storage.Models{Users: sessionTestUserModel{user: user}},
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
router := httprouter.New()
|
||||
router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler)
|
||||
router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler)
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler)
|
||||
router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler))
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler))
|
||||
|
||||
handler := app.sessions.LoadAndSave(app.enableCORS(app.authenticate(router)))
|
||||
return app, handler
|
||||
}
|
||||
|
||||
func TestBrowserSessionLifecycle(t *testing.T) {
|
||||
_, handler := newSessionTestApplication(t)
|
||||
|
||||
loginBody := []byte(`{"email":"alice@example.com","password":"correct horse battery staple"}`)
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader(loginBody))
|
||||
loginRequest.Header.Set("Content-Type", "application/json")
|
||||
loginRequest.Header.Set("Origin", "http://localhost:8080")
|
||||
loginResponse := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(loginResponse, loginRequest)
|
||||
|
||||
if loginResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("login status: got %d, want %d; body: %s", loginResponse.Code, http.StatusCreated, loginResponse.Body.String())
|
||||
}
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range loginResponse.Result().Cookies() {
|
||||
if cookie.Name == "gardomatic_session" {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("login response did not contain a session cookie")
|
||||
}
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Error("session cookie is not HttpOnly")
|
||||
}
|
||||
|
||||
showRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
||||
showRequest.Header.Set("Origin", "http://localhost:8080")
|
||||
showRequest.AddCookie(sessionCookie)
|
||||
showResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(showResponse, showRequest)
|
||||
|
||||
if showResponse.Code != http.StatusOK {
|
||||
t.Fatalf("show session status: got %d, want %d; body: %s", showResponse.Code, http.StatusOK, showResponse.Body.String())
|
||||
}
|
||||
if got := showResponse.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("Access-Control-Allow-Credentials: got %q, want %q", got, "true")
|
||||
}
|
||||
|
||||
listRequest := httptest.NewRequest(http.MethodGet, "/v1/account/sessions", nil)
|
||||
listRequest.AddCookie(sessionCookie)
|
||||
listResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(listResponse, listRequest)
|
||||
if listResponse.Code != http.StatusOK {
|
||||
t.Fatalf("list account sessions status: got %d, want %d; body: %s", listResponse.Code, http.StatusOK, listResponse.Body.String())
|
||||
}
|
||||
var listed struct {
|
||||
Sessions []accountSession `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(listResponse.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listed.Sessions) != 1 || !listed.Sessions[0].Current || listed.Sessions[0].ID == "" {
|
||||
t.Fatalf("listed sessions = %+v, want one current session", listed.Sessions)
|
||||
}
|
||||
|
||||
invalidBearerRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
||||
invalidBearerRequest.Header.Set("Origin", "http://localhost:8080")
|
||||
invalidBearerRequest.Header.Set("Authorization", "Bearer invalid")
|
||||
invalidBearerRequest.AddCookie(sessionCookie)
|
||||
invalidBearerResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(invalidBearerResponse, invalidBearerRequest)
|
||||
|
||||
if invalidBearerResponse.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid bearer status: got %d, want %d", invalidBearerResponse.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
logoutRequest := httptest.NewRequest(http.MethodDelete, "/v1/account/sessions/"+listed.Sessions[0].ID, nil)
|
||||
logoutRequest.Header.Set("Origin", "http://localhost:8080")
|
||||
logoutRequest.AddCookie(sessionCookie)
|
||||
logoutResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(logoutResponse, logoutRequest)
|
||||
|
||||
if logoutResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout status: got %d, want %d", logoutResponse.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
showAfterLogoutRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil)
|
||||
showAfterLogoutRequest.Header.Set("Origin", "http://localhost:8080")
|
||||
showAfterLogoutResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(showAfterLogoutResponse, showAfterLogoutRequest)
|
||||
|
||||
if showAfterLogoutResponse.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("show after logout status: got %d, want %d", showAfterLogoutResponse.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionEndpointRejectsUntrustedBrowserOrigin(t *testing.T) {
|
||||
_, handler := newSessionTestApplication(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader([]byte(`{}`)))
|
||||
request.Header.Set("Origin", "https://attacker.example")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status: got %d, want %d", response.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type speciesInput struct {
|
||||
Global bool `json:"global"`
|
||||
Tags []string `json:"tags"`
|
||||
CommonName *string `json:"common_name"`
|
||||
Cultivar *string `json:"cultivar"`
|
||||
BotanicalName *string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id"`
|
||||
ClearCategoryID bool `json:"clear_category_id"`
|
||||
SunExposure *string `json:"sun_exposure"`
|
||||
SoilCondition *string `json:"soil_condition"`
|
||||
SoilReaction *string `json:"soil_reaction"`
|
||||
WinterProtection *string `json:"winter_protection"`
|
||||
SpacingCM *int `json:"spacing_cm"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
SowMonthFrom *int `json:"sow_month_from"`
|
||||
SowDayFrom *int `json:"sow_day_from"`
|
||||
ClearSowDayFrom bool `json:"clear_sow_day_from"`
|
||||
SowMonthTo *int `json:"sow_month_to"`
|
||||
SowDayTo *int `json:"sow_day_to"`
|
||||
ClearSowDayTo bool `json:"clear_sow_day_to"`
|
||||
PlantingMonthFrom *int `json:"planting_month_from"`
|
||||
PlantingDayFrom *int `json:"planting_day_from"`
|
||||
ClearPlantingDayFrom bool `json:"clear_planting_day_from"`
|
||||
PlantingMonthTo *int `json:"planting_month_to"`
|
||||
PlantingDayTo *int `json:"planting_day_to"`
|
||||
ClearPlantingDayTo bool `json:"clear_planting_day_to"`
|
||||
HarvestMonthFrom *int `json:"harvest_month_from"`
|
||||
HarvestDayFrom *int `json:"harvest_day_from"`
|
||||
ClearHarvestDayFrom bool `json:"clear_harvest_day_from"`
|
||||
HarvestMonthTo *int `json:"harvest_month_to"`
|
||||
HarvestDayTo *int `json:"harvest_day_to"`
|
||||
ClearHarvestDayTo bool `json:"clear_harvest_day_to"`
|
||||
ClearSowRange bool `json:"clear_sow_range"`
|
||||
ClearPlantingRange bool `json:"clear_planting_range"`
|
||||
ClearHarvestRange bool `json:"clear_harvest_range"`
|
||||
Notes *string `json:"notes"`
|
||||
ImageData *string `json:"image_data"`
|
||||
ImageID *int `json:"image_id"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
}
|
||||
|
||||
func (input speciesInput) apply(species *storage.Species) {
|
||||
assignTrimmed(input.CommonName, &species.CommonName)
|
||||
assignTrimmed(input.Cultivar, &species.Cultivar)
|
||||
assignTrimmed(input.BotanicalName, &species.BotanicalName)
|
||||
assignIntPointer(input.CategoryID, &species.CategoryID)
|
||||
if input.ClearCategoryID {
|
||||
species.CategoryID = nil
|
||||
species.Category = ""
|
||||
}
|
||||
assignTrimmed(input.Notes, &species.Notes)
|
||||
if input.ImageData != nil {
|
||||
species.ImageData = *input.ImageData
|
||||
}
|
||||
assignStringPointer(input.SunExposure, &species.SunExposure)
|
||||
assignStringPointer(input.SoilCondition, &species.SoilCondition)
|
||||
assignStringPointer(input.SoilReaction, &species.SoilReaction)
|
||||
assignStringPointer(input.WinterProtection, &species.WinterProtection)
|
||||
assignIntPointer(input.SpacingCM, &species.SpacingCM)
|
||||
assignIntPointer(input.HeightCM, &species.HeightCM)
|
||||
assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom)
|
||||
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
|
||||
assignIntPointer(input.SowMonthTo, &species.SowMonthTo)
|
||||
assignIntPointer(input.SowDayTo, &species.SowDayTo)
|
||||
assignIntPointer(input.PlantingMonthFrom, &species.PlantingMonthFrom)
|
||||
assignIntPointer(input.PlantingDayFrom, &species.PlantingDayFrom)
|
||||
assignIntPointer(input.PlantingMonthTo, &species.PlantingMonthTo)
|
||||
assignIntPointer(input.PlantingDayTo, &species.PlantingDayTo)
|
||||
assignIntPointer(input.HarvestMonthFrom, &species.HarvestMonthFrom)
|
||||
assignIntPointer(input.HarvestDayFrom, &species.HarvestDayFrom)
|
||||
assignIntPointer(input.HarvestMonthTo, &species.HarvestMonthTo)
|
||||
assignIntPointer(input.HarvestDayTo, &species.HarvestDayTo)
|
||||
if input.ClearSowRange {
|
||||
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo = nil, nil, nil, nil
|
||||
}
|
||||
if input.ClearHarvestRange {
|
||||
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo = nil, nil, nil, nil
|
||||
}
|
||||
if input.ClearPlantingRange {
|
||||
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo = nil, nil, nil, nil
|
||||
}
|
||||
if input.ClearSowDayFrom {
|
||||
species.SowDayFrom = nil
|
||||
}
|
||||
if input.ClearSowDayTo {
|
||||
species.SowDayTo = nil
|
||||
}
|
||||
if input.ClearPlantingDayFrom {
|
||||
species.PlantingDayFrom = nil
|
||||
}
|
||||
if input.ClearPlantingDayTo {
|
||||
species.PlantingDayTo = nil
|
||||
}
|
||||
if input.ClearHarvestDayFrom {
|
||||
species.HarvestDayFrom = nil
|
||||
}
|
||||
if input.ClearHarvestDayTo {
|
||||
species.HarvestDayTo = nil
|
||||
}
|
||||
if len(input.Attributes) != 0 {
|
||||
species.Attributes = input.Attributes
|
||||
}
|
||||
}
|
||||
|
||||
func assignTrimmed(input *string, destination *string) {
|
||||
if input != nil {
|
||||
*destination = strings.TrimSpace(*input)
|
||||
}
|
||||
}
|
||||
|
||||
func assignStringPointer(input *string, destination **string) {
|
||||
if input != nil {
|
||||
value := strings.TrimSpace(*input)
|
||||
if value == "" {
|
||||
*destination = nil
|
||||
} else {
|
||||
*destination = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignIntPointer(input *int, destination **int) {
|
||||
if input != nil {
|
||||
*destination = input
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
var input speciesInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if input.Global {
|
||||
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else if !member.Can(storage.GardenPermissionSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var ownerGardenID *int
|
||||
if !input.Global {
|
||||
ownerGardenID = &gardenID
|
||||
}
|
||||
species := storage.Species{GardenID: ownerGardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID}
|
||||
input.apply(&species)
|
||||
changedCategoryID := input.CategoryID
|
||||
if input.ClearCategoryID {
|
||||
changedCategoryID = nil
|
||||
}
|
||||
if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, nil) {
|
||||
return
|
||||
}
|
||||
species.Tags = storage.NormalizeTags(input.Tags)
|
||||
v := validate.New()
|
||||
validateImageData(v, species.ImageData)
|
||||
if storage.ValidateSpecies(v, species); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
imageID, err := app.resolveImage(gardenID, species.ImageData, input.ImageID, user.ID, "species")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
species.ImageID = imageID
|
||||
species.ImageData = ""
|
||||
species, err = app.models.Species.Insert(species)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
tagScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
tagScope = 0
|
||||
}
|
||||
species.Tags, err = app.saveTags(tagScope, storage.TagEntitySpecies, species.ID, species.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d", gardenID, species.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"species": species}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
allSpecies, err := app.models.Species.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range allSpecies {
|
||||
tagScope := gardenID
|
||||
if allSpecies[i].GardenID == nil {
|
||||
tagScope = 0
|
||||
}
|
||||
allSpecies[i].Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, allSpecies[i].ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"species": allSpecies}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
species, err := app.models.Species.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
tagScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
tagScope = 0
|
||||
}
|
||||
species.Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, species.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
species, err := app.models.Species.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if species.GardenID == nil {
|
||||
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else if *species.GardenID != gardenID {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
} else if !member.Can(storage.GardenPermissionSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var input speciesInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
previousCategoryID := species.CategoryID
|
||||
previousImageID := species.ImageID
|
||||
previousImageData := species.ImageData
|
||||
input.apply(&species)
|
||||
species.UpdatedBy = user.ID
|
||||
changedCategoryID := input.CategoryID
|
||||
if input.ClearCategoryID {
|
||||
changedCategoryID = nil
|
||||
}
|
||||
if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, previousCategoryID) {
|
||||
return
|
||||
}
|
||||
if input.Tags != nil {
|
||||
species.Tags = storage.NormalizeTags(input.Tags)
|
||||
}
|
||||
v := validate.New()
|
||||
validateImageData(v, species.ImageData)
|
||||
if storage.ValidateSpecies(v, species); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
if input.ImageData != nil && *input.ImageData != previousImageData {
|
||||
species.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "species")
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
species.ImageData = ""
|
||||
updateScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
updateScope = 0
|
||||
}
|
||||
species, err = app.models.Species.Update(updateScope, species)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "species", EntityID: species.ID, PreviousImageID: previousImageID, ImageID: species.ImageID, ChangedBy: user.ID}); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Tags != nil {
|
||||
species.Tags, err = app.saveTags(updateScope, storage.TagEntitySpecies, species.ID, species.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) resolveSpeciesCategory(w http.ResponseWriter, r *http.Request, species *storage.Species, changedID, existingID *int) bool {
|
||||
if changedID == nil {
|
||||
return true
|
||||
}
|
||||
category, err := app.models.SpeciesCategories.Get(*changedID)
|
||||
keepsInactiveCategory := existingID != nil && *existingID == *changedID
|
||||
if err != nil || !category.Active && !keepsInactiveCategory {
|
||||
app.failedValidationResponse(w, r, map[string]string{"category_id": "must reference an active category"})
|
||||
return false
|
||||
}
|
||||
species.Category = category.Name
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *application) deleteSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
species, writable := app.requireWritableSpecies(w, r, gardenID, id)
|
||||
if !writable {
|
||||
return
|
||||
}
|
||||
deleteScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
deleteScope = 0
|
||||
}
|
||||
if err := app.models.Species.Delete(deleteScope, id); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) respondToEntityModelError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.notFoundResponse(w, r)
|
||||
case errors.Is(err, storage.ErrEditConflict):
|
||||
app.editConflictResponse(w, r)
|
||||
case errors.Is(err, storage.ErrConflict):
|
||||
app.conflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type speciesCategoryInput struct {
|
||||
Name *string `json:"name"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
Active *bool `json:"active"`
|
||||
Lifecycle *string `json:"lifecycle"`
|
||||
}
|
||||
|
||||
func (input speciesCategoryInput) apply(category *storage.SpeciesCategory) {
|
||||
if input.Name != nil {
|
||||
category.Name = strings.TrimSpace(*input.Name)
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
category.SortOrder = *input.SortOrder
|
||||
}
|
||||
if input.Active != nil {
|
||||
category.Active = *input.Active
|
||||
}
|
||||
if input.Lifecycle != nil {
|
||||
value := strings.TrimSpace(*input.Lifecycle)
|
||||
if value == "" {
|
||||
category.Lifecycle = nil
|
||||
} else {
|
||||
category.Lifecycle = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categories, err := app.models.SpeciesCategories.GetAll()
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listAdminSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categories, err := app.models.SpeciesCategories.GetAll()
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input speciesCategoryInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
category := storage.SpeciesCategory{Active: true}
|
||||
input.apply(&category)
|
||||
if !app.validateSpeciesCategory(w, r, category) {
|
||||
return
|
||||
}
|
||||
category, err := app.models.SpeciesCategories.Insert(category)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/admin/species-categories/%d", category.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"category": category}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
category, err := app.models.SpeciesCategories.Get(id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input speciesCategoryInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&category)
|
||||
if !app.validateSpeciesCategory(w, r, category) {
|
||||
return
|
||||
}
|
||||
category, err = app.models.SpeciesCategories.Update(category)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"category": category}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
category, err := app.models.SpeciesCategories.Get(id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
category.Active = false
|
||||
if _, err = app.models.SpeciesCategories.Update(category); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) validateSpeciesCategory(w http.ResponseWriter, r *http.Request, category storage.SpeciesCategory) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateSpeciesCategory(v, category)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type speciesCategoryTestModel struct {
|
||||
items map[int]storage.SpeciesCategory
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (m *speciesCategoryTestModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
|
||||
m.nextID++
|
||||
category.ID, category.Version = m.nextID, 1
|
||||
m.items[category.ID] = category
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (m *speciesCategoryTestModel) Get(id int) (storage.SpeciesCategory, error) {
|
||||
category, ok := m.items[id]
|
||||
if !ok {
|
||||
return storage.SpeciesCategory{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (m *speciesCategoryTestModel) GetAll() ([]storage.SpeciesCategory, error) {
|
||||
categories := make([]storage.SpeciesCategory, 0, len(m.items))
|
||||
for _, category := range m.items {
|
||||
categories = append(categories, category)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (m *speciesCategoryTestModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
|
||||
if _, ok := m.items[category.ID]; !ok {
|
||||
return storage.SpeciesCategory{}, storage.ErrRecordNotFound
|
||||
}
|
||||
category.Version++
|
||||
m.items[category.ID] = category
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func TestAdminSpeciesCategoryLifecycle(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
model := &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{}, nextID: 4}
|
||||
app.models.SpeciesCategories = model
|
||||
|
||||
createRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/species-categories", strings.NewReader(`{"name":" Gemüse ","sort_order":10}`))
|
||||
createResponse := httptest.NewRecorder()
|
||||
app.createAdminSpeciesCategoryHandler(createResponse, createRequest)
|
||||
if createResponse.Code != http.StatusCreated || model.items[5].Name != "Gemüse" || !model.items[5].Active {
|
||||
t.Fatalf("create category: status=%d category=%+v body=%s", createResponse.Code, model.items[5], createResponse.Body.String())
|
||||
}
|
||||
|
||||
deleteRequest := httptest.NewRequest(http.MethodDelete, "/v1/admin/species-categories/5", nil)
|
||||
deleteRequest = deleteRequest.WithContext(context.WithValue(deleteRequest.Context(), httprouter.ParamsKey, httprouter.Params{{Key: "id", Value: "5"}}))
|
||||
deleteResponse := httptest.NewRecorder()
|
||||
app.deleteAdminSpeciesCategoryHandler(deleteResponse, deleteRequest)
|
||||
if deleteResponse.Code != http.StatusNoContent || model.items[5].Active {
|
||||
t.Fatalf("deactivate category: status=%d category=%+v", deleteResponse.Code, model.items[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpeciesCategoryRejectsInactiveCategory(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
app.models.SpeciesCategories = &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{2: {ID: 2, Name: "Alt", Active: false}}}
|
||||
id := 2
|
||||
response := httptest.NewRecorder()
|
||||
if app.resolveSpeciesCategory(response, httptest.NewRequest(http.MethodPost, "/", nil), &storage.Species{CategoryID: &id}, &id, nil) {
|
||||
t.Fatal("inactive category was accepted")
|
||||
}
|
||||
if response.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type speciesTaskTemplateInput struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
TriggerType *storage.TaskTriggerType `json:"trigger_type"`
|
||||
MonthFrom *int `json:"month_from"`
|
||||
DayFrom *int `json:"day_from"`
|
||||
ClearDayFrom bool `json:"clear_day_from"`
|
||||
MonthTo *int `json:"month_to"`
|
||||
DayTo *int `json:"day_to"`
|
||||
ClearDayTo bool `json:"clear_day_to"`
|
||||
OffsetDaysFrom *int `json:"offset_days_from"`
|
||||
OffsetDaysTo *int `json:"offset_days_to"`
|
||||
IntervalDays *int `json:"interval_days"`
|
||||
ClearIntervalDays bool `json:"clear_interval_days"`
|
||||
TriggerOffset *int `json:"trigger_offset"`
|
||||
TriggerOffsetUnit *storage.TaskDurationUnit `json:"trigger_offset_unit"`
|
||||
Duration *int `json:"duration"`
|
||||
DurationUnit *storage.TaskDurationUnit `json:"duration_unit"`
|
||||
Recurrence *storage.TaskRecurrence `json:"recurrence"`
|
||||
RecurrenceInterval *int `json:"recurrence_interval"`
|
||||
Priority *int `json:"priority"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
func (input speciesTaskTemplateInput) apply(template *storage.SpeciesTaskTemplate) {
|
||||
if input.Title != nil {
|
||||
template.Title = strings.TrimSpace(*input.Title)
|
||||
}
|
||||
if input.Description != nil {
|
||||
template.Description = strings.TrimSpace(*input.Description)
|
||||
}
|
||||
if input.TriggerType != nil {
|
||||
template.TriggerType = *input.TriggerType
|
||||
}
|
||||
if input.MonthFrom != nil {
|
||||
template.MonthFrom = positivePointer(input.MonthFrom)
|
||||
}
|
||||
if input.DayFrom != nil {
|
||||
template.DayFrom = positivePointer(input.DayFrom)
|
||||
}
|
||||
if input.ClearDayFrom {
|
||||
template.DayFrom = nil
|
||||
}
|
||||
if input.MonthTo != nil {
|
||||
template.MonthTo = positivePointer(input.MonthTo)
|
||||
}
|
||||
if input.DayTo != nil {
|
||||
template.DayTo = positivePointer(input.DayTo)
|
||||
}
|
||||
if input.ClearDayTo {
|
||||
template.DayTo = nil
|
||||
}
|
||||
if input.OffsetDaysFrom != nil {
|
||||
template.OffsetDaysFrom = input.OffsetDaysFrom
|
||||
}
|
||||
if input.OffsetDaysTo != nil {
|
||||
template.OffsetDaysTo = input.OffsetDaysTo
|
||||
}
|
||||
if input.IntervalDays != nil {
|
||||
template.IntervalDays = positivePointer(input.IntervalDays)
|
||||
}
|
||||
if input.ClearIntervalDays {
|
||||
template.IntervalDays = nil
|
||||
}
|
||||
if input.TriggerOffset != nil {
|
||||
template.TriggerOffset = *input.TriggerOffset
|
||||
}
|
||||
if input.TriggerOffsetUnit != nil {
|
||||
template.TriggerOffsetUnit = *input.TriggerOffsetUnit
|
||||
}
|
||||
if input.Duration != nil {
|
||||
template.Duration = *input.Duration
|
||||
}
|
||||
if input.DurationUnit != nil {
|
||||
template.DurationUnit = *input.DurationUnit
|
||||
}
|
||||
if input.Recurrence != nil {
|
||||
template.Recurrence = *input.Recurrence
|
||||
}
|
||||
if input.RecurrenceInterval != nil {
|
||||
template.RecurrenceInterval = *input.RecurrenceInterval
|
||||
}
|
||||
if input.Priority != nil {
|
||||
template.Priority = *input.Priority
|
||||
}
|
||||
if input.Active != nil {
|
||||
template.Active = *input.Active
|
||||
}
|
||||
if template.TriggerType == storage.TaskTriggerMonthOfYear {
|
||||
template.OffsetDaysFrom, template.OffsetDaysTo = nil, nil
|
||||
} else {
|
||||
template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo = nil, nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func positivePointer(value *int) *int {
|
||||
if value != nil && *value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (app *application) createSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
speciesID, _ := app.readIDParam(r)
|
||||
if _, ok := app.requireWritableSpecies(w, r, gardenID, speciesID); !ok {
|
||||
return
|
||||
}
|
||||
var input speciesTaskTemplateInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
template := storage.SpeciesTaskTemplate{SpeciesID: speciesID, Origin: storage.TaskTemplateOriginManual, Active: true, TriggerOffsetUnit: storage.TaskDurationDay, DurationUnit: storage.TaskDurationDay, RecurrenceInterval: 1}
|
||||
input.apply(&template)
|
||||
if !app.validateSpeciesTaskTemplate(w, r, template) {
|
||||
return
|
||||
}
|
||||
template, err := app.models.SpeciesTaskTemplates.Insert(template)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/task-templates/%d", gardenID, speciesID, template.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"task_template": template}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listSpeciesTaskTemplatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
speciesID, _ := app.readIDParam(r)
|
||||
if _, err := app.models.Species.Get(gardenID, speciesID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, speciesID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"task_templates": templates}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
speciesID, _ := app.readIDParam(r)
|
||||
templateID, err := app.readNamedIDParam(r, "templateID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
|
||||
if err != nil || template.SpeciesID != speciesID {
|
||||
if err == nil {
|
||||
err = storage.ErrRecordNotFound
|
||||
}
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
speciesID, _ := app.readIDParam(r)
|
||||
templateID, err := app.readNamedIDParam(r, "templateID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID)
|
||||
if !writable {
|
||||
return
|
||||
}
|
||||
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
|
||||
if err != nil || template.SpeciesID != speciesID {
|
||||
if err == nil {
|
||||
err = storage.ErrRecordNotFound
|
||||
}
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input speciesTaskTemplateInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&template)
|
||||
if !app.validateSpeciesTaskTemplate(w, r, template) {
|
||||
return
|
||||
}
|
||||
writeScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
writeScope = 0
|
||||
}
|
||||
template, err = app.models.SpeciesTaskTemplates.Update(writeScope, template)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) deleteSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
speciesID, _ := app.readIDParam(r)
|
||||
templateID, err := app.readNamedIDParam(r, "templateID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID)
|
||||
if !writable {
|
||||
return
|
||||
}
|
||||
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
|
||||
if err != nil || template.SpeciesID != speciesID {
|
||||
if err == nil {
|
||||
err = storage.ErrRecordNotFound
|
||||
}
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeScope := gardenID
|
||||
if species.GardenID == nil {
|
||||
writeScope = 0
|
||||
}
|
||||
if template.Origin != storage.TaskTemplateOriginManual {
|
||||
template.Active = false
|
||||
if _, err := app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := app.models.SpeciesTaskTemplates.Delete(writeScope, templateID); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) requireWritableSpecies(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) (storage.Species, bool) {
|
||||
species, err := app.models.Species.Get(gardenID, speciesID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return storage.Species{}, false
|
||||
}
|
||||
if species.GardenID == nil {
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return storage.Species{}, false
|
||||
}
|
||||
return species, true
|
||||
}
|
||||
if *species.GardenID != gardenID {
|
||||
app.respondToEntityModelError(w, r, storage.ErrRecordNotFound)
|
||||
return storage.Species{}, false
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
if !member.Can(storage.GardenPermissionSpeciesWrite) {
|
||||
app.permissionDeniedResponse(w, r)
|
||||
return storage.Species{}, false
|
||||
}
|
||||
return species, true
|
||||
}
|
||||
|
||||
func (app *application) validateSpeciesTaskTemplate(w http.ResponseWriter, r *http.Request, template storage.SpeciesTaskTemplate) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateSpeciesTaskTemplate(v, template)
|
||||
if !app.validateConfiguredPriority(w, r, v, template.Priority) {
|
||||
return false
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestSpeciesTaskTemplatesRequireWritePermission(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 14, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin}
|
||||
gardenID := 3
|
||||
app.models.Species = &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global"}, 2: {ID: 2, GardenID: &gardenID, CommonName: "Rose"}}}
|
||||
templates := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 10}
|
||||
app.models.SpeciesTaskTemplates = templates
|
||||
body := []byte(`{"title":"Schneiden","trigger_type":"month_of_year","month_from":2,"day_from":15,"duration":2,"duration_unit":"week","active":true}`)
|
||||
global := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body)
|
||||
if global.Code != http.StatusForbidden {
|
||||
t.Fatalf("global write status=%d", global.Code)
|
||||
}
|
||||
created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/2/task-templates", body)
|
||||
if created.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", created.Code, created.Body.String())
|
||||
}
|
||||
listed := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/2/task-templates", nil)
|
||||
if listed.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listed.Code, listed.Body.String())
|
||||
}
|
||||
user.Role = storage.ApplicationRoleAdmin
|
||||
global = serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body)
|
||||
if global.Code != http.StatusCreated {
|
||||
t.Fatalf("global admin write status=%d body=%s", global.Code, global.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) listGardenTagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
tags, err := app.models.Tags.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"tags": tags}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) loadTags(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) {
|
||||
if app.models.Tags == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return app.models.Tags.Get(gardenID, entity, entityID)
|
||||
}
|
||||
|
||||
func (app *application) saveTags(gardenID int, entity storage.TagEntity, entityID int, values []string) ([]string, error) {
|
||||
values = storage.NormalizeTags(values)
|
||||
if app.models.Tags == nil {
|
||||
return values, nil
|
||||
}
|
||||
return app.models.Tags.Set(gardenID, entity, entityID, values)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/daterange"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) generateTasks(gardenID, userID int, now time.Time) error {
|
||||
plants, err := app.models.Plants.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := app.models.Tasks.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, plant := range plants {
|
||||
if plant.SpeciesID == nil || (plant.Status != "alive" && plant.Status != "active") {
|
||||
continue
|
||||
}
|
||||
var species storage.Species
|
||||
templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, *plant.SpeciesID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, template := range templates {
|
||||
if !template.Active {
|
||||
continue
|
||||
}
|
||||
if template.TriggerType == storage.TaskTriggerRelativeToSowing || template.TriggerType == storage.TaskTriggerRelativeToHarvest || template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting {
|
||||
species, err = app.models.Species.Get(gardenID, *plant.SpeciesID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if app.models.TaskTemplateOptOuts != nil {
|
||||
optedOut, optErr := app.models.TaskTemplateOptOuts.IsOptedOut(gardenID, plant.ID, template.ID)
|
||||
if optErr != nil {
|
||||
return optErr
|
||||
}
|
||||
if optedOut {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, task := range generatedTasksForTemplate(plant, species, template, existing, gardenID, userID, now) {
|
||||
if _, err := app.models.Tasks.Insert(task); err != nil && !errors.Is(err, storage.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatedTasksForTemplate(plant storage.Plant, species storage.Species, template storage.SpeciesTaskTemplate, existing []storage.Task, gardenID, userID int, now time.Time) []storage.Task {
|
||||
var start, generatedFor time.Time
|
||||
switch template.TriggerType {
|
||||
case storage.TaskTriggerMonthOfYear:
|
||||
if template.MonthFrom == nil || template.DayFrom == nil {
|
||||
return nil
|
||||
}
|
||||
start = nextAnnualStart(now, *template.MonthFrom, *template.DayFrom)
|
||||
generatedFor = daterange.Date(start)
|
||||
case storage.TaskTriggerRelativeToPlanting:
|
||||
if plant.AcquiredAt == nil {
|
||||
return nil
|
||||
}
|
||||
base := daterange.Date(*plant.AcquiredAt)
|
||||
start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base
|
||||
case storage.TaskTriggerRelativeToLastTask:
|
||||
base, ok := latestCompletion(existing, plant.ID, template.ID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
base = daterange.Date(base)
|
||||
start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base
|
||||
case storage.TaskTriggerRelativeToSowing, storage.TaskTriggerRelativeToHarvest, storage.TaskTriggerRelativeToSpeciesPlanting:
|
||||
month, day := species.SowMonthFrom, species.SowDayFrom
|
||||
if template.TriggerType == storage.TaskTriggerRelativeToHarvest {
|
||||
month, day = species.HarvestMonthFrom, species.HarvestDayFrom
|
||||
} else if template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting {
|
||||
month, day = species.PlantingMonthFrom, species.PlantingDayFrom
|
||||
}
|
||||
if month == nil {
|
||||
return nil
|
||||
}
|
||||
d := 1
|
||||
if day != nil {
|
||||
d = *day
|
||||
}
|
||||
base := nextAnnualStart(now, *month, d)
|
||||
start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), daterange.Date(base)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
makeTask := func(windowStart, windowEnd, slot time.Time) storage.Task {
|
||||
plantID, templateID := plant.ID, template.ID
|
||||
return storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: template.Title, Description: template.Description, DueAtStart: &windowStart, DueAtEnd: &windowEnd, GeneratedFor: &slot, Recurrence: template.Recurrence, RecurrenceInterval: template.RecurrenceInterval, Priority: template.Priority, Active: true, CreatedBy: userID}
|
||||
}
|
||||
end := endOfDay(addTemplateDuration(start, template.Duration, template.DurationUnit))
|
||||
return []storage.Task{makeTask(start, end, generatedFor)}
|
||||
}
|
||||
|
||||
func nextAnnualStart(now time.Time, month, day int) time.Time {
|
||||
lastDay := time.Date(now.Year(), time.Month(month)+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
result := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, now.Location())
|
||||
if result.Before(daterange.Date(now)) {
|
||||
result = result.AddDate(1, 0, 0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func addTemplateDuration(value time.Time, amount int, unit storage.TaskDurationUnit) time.Time {
|
||||
switch unit {
|
||||
case storage.TaskDurationWeek:
|
||||
return value.AddDate(0, 0, amount*7)
|
||||
case storage.TaskDurationMonth:
|
||||
return addClampedDate(value, 0, amount)
|
||||
default:
|
||||
return value.AddDate(0, 0, amount)
|
||||
}
|
||||
}
|
||||
|
||||
func endOfDay(value time.Time) time.Time {
|
||||
return daterange.Date(value).AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
}
|
||||
|
||||
func latestCompletion(tasks []storage.Task, plantID, templateID int) (time.Time, bool) {
|
||||
var latest time.Time
|
||||
for _, task := range tasks {
|
||||
if task.PlantID != nil && *task.PlantID == plantID && task.TemplateID != nil && *task.TemplateID == templateID && task.CompletedAt != nil && task.CompletedAt.After(latest) {
|
||||
latest = *task.CompletedAt
|
||||
}
|
||||
}
|
||||
return latest, !latest.IsZero()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type templateTestModel struct {
|
||||
items map[int]storage.SpeciesTaskTemplate
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (m *templateTestModel) Insert(value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
|
||||
m.nextID++
|
||||
value.ID, value.Version = m.nextID, 1
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *templateTestModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) {
|
||||
value, ok := m.items[id]
|
||||
if !ok {
|
||||
return storage.SpeciesTaskTemplate{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (m *templateTestModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) {
|
||||
result := []storage.SpeciesTaskTemplate{}
|
||||
for _, value := range m.items {
|
||||
if value.SpeciesID == speciesID {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (m *templateTestModel) Update(gardenID int, value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
|
||||
value.Version++
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *templateTestModel) Delete(gardenID, id int) error { delete(m.items, id); return nil }
|
||||
|
||||
func TestGenerateTasksIsIdempotentAndHandlesWrapAround(t *testing.T) {
|
||||
app, _, _ := newGardenTestApplication()
|
||||
speciesID, monthFrom, dayFrom := 2, 11, 1
|
||||
app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, SpeciesID: &speciesID, Name: "Rose", Status: "active"}}}
|
||||
app.models.SpeciesTaskTemplates = &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{7: {ID: 7, SpeciesID: speciesID, Title: "Winterschutz prüfen", TriggerType: storage.TaskTriggerMonthOfYear, MonthFrom: &monthFrom, DayFrom: &dayFrom, Duration: 3, DurationUnit: storage.TaskDurationMonth, Recurrence: storage.TaskRecurrenceWeekly, RecurrenceInterval: 2, Active: true}}}
|
||||
tasks := &taskTestModel{items: map[int]storage.Task{}, nextID: 10}
|
||||
app.models.Tasks = tasks
|
||||
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||
if err := app.generateTasks(3, 12, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstCount := len(tasks.items)
|
||||
if firstCount != 1 {
|
||||
t.Fatalf("generated count=%d", firstCount)
|
||||
}
|
||||
if err := app.generateTasks(3, 12, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks.items) != firstCount {
|
||||
t.Fatalf("second generation count=%d want=%d", len(tasks.items), firstCount)
|
||||
}
|
||||
for _, task := range tasks.items {
|
||||
if task.Recurrence != storage.TaskRecurrenceWeekly || task.RecurrenceInterval != 2 {
|
||||
t.Errorf("recurrence not copied to generated task: %+v", task)
|
||||
}
|
||||
if task.DueAtStart.Before(time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)) || task.DueAtEnd.After(endOfDay(time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC))) {
|
||||
t.Errorf("window outside wrap range: %+v", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type taskPriorityInput struct {
|
||||
Name *string `json:"name"`
|
||||
Value *int `json:"value"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
func (input taskPriorityInput) apply(value *storage.TaskPriority) {
|
||||
if input.Name != nil {
|
||||
value.Name = strings.TrimSpace(*input.Name)
|
||||
}
|
||||
if input.Value != nil {
|
||||
value.Value = *input.Value
|
||||
}
|
||||
if input.SortOrder != nil {
|
||||
value.SortOrder = *input.SortOrder
|
||||
}
|
||||
if input.Active != nil {
|
||||
value.Active = *input.Active
|
||||
}
|
||||
}
|
||||
func (app *application) listTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.writeTaskPriorities(w, r, false)
|
||||
}
|
||||
func (app *application) listAdminTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.writeTaskPriorities(w, r, true)
|
||||
}
|
||||
func (app *application) writeTaskPriorities(w http.ResponseWriter, r *http.Request, includeInactive bool) {
|
||||
values, err := app.models.TaskPriorities.GetAll()
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if !includeInactive {
|
||||
active := values[:0]
|
||||
for _, value := range values {
|
||||
if value.Active {
|
||||
active = append(active, value)
|
||||
}
|
||||
}
|
||||
values = active
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"priorities": values}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
func (app *application) createAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input taskPriorityInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
value := storage.TaskPriority{Active: true}
|
||||
input.apply(&value)
|
||||
if !app.validateTaskPriority(w, r, value) {
|
||||
return
|
||||
}
|
||||
value, err := app.models.TaskPriorities.Insert(value)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/admin/task-priorities/%d", value.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"priority": value}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
func (app *application) updateAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
value, err := app.models.TaskPriorities.Get(id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input taskPriorityInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
input.apply(&value)
|
||||
if !app.validateTaskPriority(w, r, value) {
|
||||
return
|
||||
}
|
||||
value, err = app.models.TaskPriorities.Update(value)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"priority": value}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
func (app *application) deleteAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
value, err := app.models.TaskPriorities.Get(id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
value.Active = false
|
||||
if _, err = app.models.TaskPriorities.Update(value); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (app *application) validateTaskPriority(w http.ResponseWriter, r *http.Request, value storage.TaskPriority) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateTaskPriority(v, value)
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *application) validateConfiguredPriority(w http.ResponseWriter, r *http.Request, v *validate.Validator, value int) bool {
|
||||
if app.models.TaskPriorities == nil {
|
||||
return true
|
||||
}
|
||||
priorities, err := app.models.TaskPriorities.GetAll()
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
for _, priority := range priorities {
|
||||
if priority.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
v.AddError("priority", "must refer to a configured task priority")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (app *application) listTaskTemplateOptOutsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, err := app.readNamedIDParam(r, "id")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
ids, err := app.models.TaskTemplateOptOuts.GetAllForPlant(gardenID, plantID)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err = app.writeJSON(w, http.StatusOK, envelope{"template_ids": ids}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
func (app *application) setTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.setTaskTemplateOptOut(w, r, true)
|
||||
}
|
||||
func (app *application) deleteTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.setTaskTemplateOptOut(w, r, false)
|
||||
}
|
||||
func (app *application) setTaskTemplateOptOut(w http.ResponseWriter, r *http.Request, optedOut bool) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
plantID, err := app.readNamedIDParam(r, "id")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
templateID, err := app.readNamedIDParam(r, "templateID")
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if err = app.models.TaskTemplateOptOuts.Set(gardenID, plantID, templateID, optedOut); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type taskInput struct {
|
||||
Tags []string `json:"tags"`
|
||||
PlantID *int `json:"plant_id"`
|
||||
LocationID *int `json:"location_id"`
|
||||
ClearPlantID bool `json:"clear_plant_id"`
|
||||
ClearLocationID bool `json:"clear_location_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
DueAtStart *time.Time `json:"due_at_start"`
|
||||
DueAtEnd *time.Time `json:"due_at_end"`
|
||||
ClearDueAtStart bool `json:"clear_due_at_start"`
|
||||
ClearDueAtEnd bool `json:"clear_due_at_end"`
|
||||
Recurrence *storage.TaskRecurrence `json:"recurrence"`
|
||||
RecurrenceInterval *int `json:"recurrence_interval"`
|
||||
Priority *int `json:"priority"`
|
||||
Active *bool `json:"active"`
|
||||
Completed *bool `json:"completed"`
|
||||
PlantStatusOnCompletion *string `json:"plant_status_on_completion"`
|
||||
}
|
||||
|
||||
func (input taskInput) isCompletionOnly() bool {
|
||||
return input.Completed != nil && input.Tags == nil && input.PlantID == nil && input.LocationID == nil && !input.ClearPlantID && !input.ClearLocationID && input.Title == nil && input.Description == nil && input.DueAtStart == nil && input.DueAtEnd == nil && !input.ClearDueAtStart && !input.ClearDueAtEnd && input.Recurrence == nil && input.RecurrenceInterval == nil && input.Priority == nil && input.Active == nil && input.PlantStatusOnCompletion == nil
|
||||
}
|
||||
|
||||
func (input taskInput) apply(task *storage.Task, userID int) {
|
||||
if input.PlantID != nil {
|
||||
task.PlantID = input.PlantID
|
||||
}
|
||||
if input.LocationID != nil {
|
||||
task.LocationID = input.LocationID
|
||||
}
|
||||
if input.ClearPlantID {
|
||||
task.PlantID = nil
|
||||
}
|
||||
if input.ClearLocationID {
|
||||
task.LocationID = nil
|
||||
}
|
||||
if input.Title != nil {
|
||||
task.Title = strings.TrimSpace(*input.Title)
|
||||
}
|
||||
if input.Description != nil {
|
||||
task.Description = strings.TrimSpace(*input.Description)
|
||||
}
|
||||
if input.DueAtStart != nil {
|
||||
task.DueAtStart = input.DueAtStart
|
||||
}
|
||||
if input.DueAtEnd != nil {
|
||||
task.DueAtEnd = input.DueAtEnd
|
||||
}
|
||||
if input.ClearDueAtStart {
|
||||
task.DueAtStart = nil
|
||||
}
|
||||
if input.ClearDueAtEnd {
|
||||
task.DueAtEnd = nil
|
||||
}
|
||||
if input.Recurrence != nil {
|
||||
task.Recurrence = *input.Recurrence
|
||||
}
|
||||
if input.RecurrenceInterval != nil {
|
||||
task.RecurrenceInterval = *input.RecurrenceInterval
|
||||
}
|
||||
if input.Priority != nil {
|
||||
task.Priority = *input.Priority
|
||||
}
|
||||
if input.Active != nil {
|
||||
task.Active = *input.Active
|
||||
}
|
||||
if input.Completed != nil {
|
||||
if *input.Completed {
|
||||
now := time.Now().UTC()
|
||||
task.CompletedAt, task.CompletedBy = &now, &userID
|
||||
} else {
|
||||
task.CompletedAt, task.CompletedBy = nil, nil
|
||||
}
|
||||
}
|
||||
assignStringPointer(input.PlantStatusOnCompletion, &task.PlantStatusOnCompletion)
|
||||
}
|
||||
|
||||
func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
var input taskInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
task := storage.Task{GardenID: gardenID, CreatedBy: user.ID, Active: true, RecurrenceInterval: 1}
|
||||
input.apply(&task, user.ID)
|
||||
if task.RecurrenceInterval < 1 {
|
||||
task.RecurrenceInterval = 1
|
||||
}
|
||||
task.Tags = storage.NormalizeTags(input.Tags)
|
||||
if !app.validateTaskForGarden(w, r, gardenID, task) {
|
||||
return
|
||||
}
|
||||
task, err := app.models.Tasks.Insert(task)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/tasks/%d", gardenID, task.ID))
|
||||
if err := app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
if err := app.generateTasks(gardenID, user.ID, time.Now()); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
tasks, err := app.models.Tasks.GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
member, _ := app.contextGetGardenMember(r)
|
||||
visible := tasks[:0]
|
||||
for i := range tasks {
|
||||
if tasks[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionTaskReadOther) {
|
||||
continue
|
||||
}
|
||||
if tasks[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionTaskReadOwn) {
|
||||
continue
|
||||
}
|
||||
tasks[i].Tags, err = app.loadTags(gardenID, storage.TagEntityTask, tasks[i].ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
visible = append(visible, tasks[i])
|
||||
}
|
||||
tasks = visible
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"tasks": tasks}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
task, err := app.models.Tasks.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther) {
|
||||
return
|
||||
}
|
||||
task.Tags, err = app.loadTags(gardenID, storage.TagEntityTask, task.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
task, err := app.models.Tasks.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
var input taskInput
|
||||
if err := app.readJSON(w, r, &input); err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.isCompletionOnly() {
|
||||
if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskCompleteOwn, storage.GardenPermissionTaskCompleteOther) {
|
||||
return
|
||||
}
|
||||
} else if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskUpdateOwn, storage.GardenPermissionTaskUpdateOther) {
|
||||
return
|
||||
}
|
||||
user, _ := app.contextGetAuthenticatedUser(r)
|
||||
input.apply(&task, user.ID)
|
||||
if task.RecurrenceInterval < 1 {
|
||||
task.RecurrenceInterval = 1
|
||||
}
|
||||
if input.Tags != nil {
|
||||
task.Tags = storage.NormalizeTags(input.Tags)
|
||||
}
|
||||
if !app.validateTaskForGarden(w, r, gardenID, task) {
|
||||
return
|
||||
}
|
||||
task, err = app.models.Tasks.Update(gardenID, task)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Completed != nil && *input.Completed && task.PlantID != nil && task.PlantStatusOnCompletion != nil {
|
||||
plant, plantErr := app.models.Plants.Get(gardenID, *task.PlantID)
|
||||
if plantErr != nil {
|
||||
app.respondToEntityModelError(w, r, plantErr)
|
||||
return
|
||||
}
|
||||
plant.Status, plant.UpdatedBy = *task.PlantStatusOnCompletion, user.ID
|
||||
if _, plantErr = app.models.Plants.Update(gardenID, plant); plantErr != nil {
|
||||
app.respondToEntityModelError(w, r, plantErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if input.Tags != nil {
|
||||
task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if input.Completed != nil && *input.Completed && task.Recurrence != storage.TaskRecurrenceNone {
|
||||
if err := app.ensureNextRecurringTask(gardenID, user.ID, task); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) ensureNextRecurringTask(gardenID, userID int, task storage.Task) error {
|
||||
next := task
|
||||
next.ID, next.Version = 0, 0
|
||||
next.GeneratedFor = nil
|
||||
next.CompletedAt, next.CompletedBy = nil, nil
|
||||
next.CreatedBy = userID
|
||||
next.CreatedAt, next.UpdatedAt = time.Time{}, time.Time{}
|
||||
next.RepeatFromID = &task.ID
|
||||
next.DueAtStart = advanceRecurringTime(task.DueAtStart, task.Recurrence, task.RecurrenceInterval)
|
||||
next.DueAtEnd = advanceRecurringTime(task.DueAtEnd, task.Recurrence, task.RecurrenceInterval)
|
||||
if next.TemplateID != nil && next.DueAtStart != nil {
|
||||
generatedFor := time.Date(next.DueAtStart.Year(), next.DueAtStart.Month(), next.DueAtStart.Day(), 0, 0, 0, 0, next.DueAtStart.Location())
|
||||
next.GeneratedFor = &generatedFor
|
||||
}
|
||||
|
||||
created, err := app.models.Tasks.Insert(next)
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := app.loadTags(gardenID, storage.TagEntityTask, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = app.saveTags(gardenID, storage.TagEntityTask, created.ID, tags)
|
||||
return err
|
||||
}
|
||||
|
||||
func advanceRecurringTime(value *time.Time, recurrence storage.TaskRecurrence, interval int) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := *value
|
||||
if interval < 1 {
|
||||
interval = 1
|
||||
}
|
||||
switch recurrence {
|
||||
case storage.TaskRecurrenceDaily:
|
||||
result = result.AddDate(0, 0, interval)
|
||||
case storage.TaskRecurrenceWeekly:
|
||||
result = result.AddDate(0, 0, 7*interval)
|
||||
case storage.TaskRecurrenceMonthly:
|
||||
result = addClampedDate(result, 0, interval)
|
||||
case storage.TaskRecurrenceYearly:
|
||||
result = addClampedDate(result, interval, 0)
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
func addClampedDate(value time.Time, years, months int) time.Time {
|
||||
targetMonth := int(value.Month()) + months
|
||||
targetYear := value.Year() + years + (targetMonth-1)/12
|
||||
targetMonth = (targetMonth-1)%12 + 1
|
||||
lastDay := time.Date(targetYear, time.Month(targetMonth)+1, 0, 0, 0, 0, 0, value.Location()).Day()
|
||||
day := value.Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(targetYear, time.Month(targetMonth), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
|
||||
}
|
||||
|
||||
func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
id, err := app.readIDParam(r)
|
||||
if err != nil {
|
||||
app.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
task, err := app.models.Tasks.Get(gardenID, id)
|
||||
if err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskDeleteOwn, storage.GardenPermissionTaskDeleteOther) {
|
||||
return
|
||||
}
|
||||
if err := app.models.Tasks.Delete(gardenID, id); err != nil {
|
||||
app.respondToEntityModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *application) validateTaskForGarden(w http.ResponseWriter, r *http.Request, gardenID int, task storage.Task) bool {
|
||||
v := validate.New()
|
||||
storage.ValidateTask(v, task)
|
||||
storage.ValidateTags(v, task.Tags)
|
||||
if !app.validateConfiguredPriority(w, r, v, task.Priority) {
|
||||
return false
|
||||
}
|
||||
if task.PlantID != nil && *task.PlantID > 0 {
|
||||
if _, err := app.models.Plants.Get(gardenID, *task.PlantID); err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
v.AddError("plant_id", "must refer to a plant in this garden")
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if task.LocationID != nil && *task.LocationID > 0 {
|
||||
if _, err := app.models.Locations.Get(gardenID, *task.LocationID); err != nil {
|
||||
if errors.Is(err, storage.ErrRecordNotFound) {
|
||||
v.AddError("location_id", "must refer to a location in this garden")
|
||||
} else {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
type taskTestModel struct {
|
||||
items map[int]storage.Task
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (m *taskTestModel) Insert(value storage.Task) (storage.Task, error) {
|
||||
if value.RepeatFromID != nil {
|
||||
for _, existing := range m.items {
|
||||
if existing.RepeatFromID != nil && *existing.RepeatFromID == *value.RepeatFromID {
|
||||
return storage.Task{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.TemplateID != nil && value.PlantID != nil && value.GeneratedFor != nil {
|
||||
for _, existing := range m.items {
|
||||
if existing.TemplateID != nil && *existing.TemplateID == *value.TemplateID && existing.PlantID != nil && *existing.PlantID == *value.PlantID && existing.GeneratedFor != nil && existing.GeneratedFor.Format("2006-01-02") == value.GeneratedFor.Format("2006-01-02") {
|
||||
return storage.Task{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
m.nextID++
|
||||
value.ID, value.Version = m.nextID, 1
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func TestCompletingRecurringTaskCreatesNextCalendarOccurrence(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 12, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
dueStart := time.Date(2026, time.January, 31, 8, 0, 0, 0, time.UTC)
|
||||
dueEnd := time.Date(2026, time.January, 31, 18, 0, 0, 0, time.UTC)
|
||||
app.models.Tasks = &taskTestModel{items: map[int]storage.Task{101: {
|
||||
ID: 101, GardenID: 3, Title: "Düngen", DueAtStart: &dueStart, DueAtEnd: &dueEnd,
|
||||
Recurrence: storage.TaskRecurrenceMonthly, RecurrenceInterval: 2, Active: true, CreatedBy: user.ID, Version: 1,
|
||||
}}, nextID: 101}
|
||||
|
||||
completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`))
|
||||
if completed.Code != http.StatusOK {
|
||||
t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String())
|
||||
}
|
||||
next := app.models.Tasks.(*taskTestModel).items[102]
|
||||
if next.RepeatFromID == nil || *next.RepeatFromID != 101 || next.CompletedAt != nil {
|
||||
t.Fatalf("next occurrence links: %+v", next)
|
||||
}
|
||||
if got := next.DueAtStart.Format(time.RFC3339); got != "2026-03-31T08:00:00Z" {
|
||||
t.Errorf("next start = %s", got)
|
||||
}
|
||||
if got := next.DueAtEnd.Format(time.RFC3339); got != "2026-03-31T18:00:00Z" {
|
||||
t.Errorf("next end = %s", got)
|
||||
}
|
||||
|
||||
completedAgain := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`))
|
||||
if completedAgain.Code != http.StatusOK || len(app.models.Tasks.(*taskTestModel).items) != 2 {
|
||||
t.Fatalf("repeated completion created a duplicate: status=%d tasks=%+v", completedAgain.Code, app.models.Tasks.(*taskTestModel).items)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *taskTestModel) Get(gardenID, id int) (storage.Task, error) {
|
||||
value, ok := m.items[id]
|
||||
if !ok || value.GardenID != gardenID {
|
||||
return storage.Task{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (m *taskTestModel) GetAllForGarden(gardenID int) ([]storage.Task, error) {
|
||||
result := []storage.Task{}
|
||||
for _, value := range m.items {
|
||||
if value.GardenID == gardenID {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (m *taskTestModel) Update(gardenID int, value storage.Task) (storage.Task, error) {
|
||||
if _, err := m.Get(gardenID, value.ID); err != nil {
|
||||
return storage.Task{}, err
|
||||
}
|
||||
value.Version++
|
||||
m.items[value.ID] = value
|
||||
return value, nil
|
||||
}
|
||||
func (m *taskTestModel) Delete(gardenID, id int) error {
|
||||
if _, err := m.Get(gardenID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTasksAreScopedAndValidateReferences(t *testing.T) {
|
||||
app, _, members := newGardenTestApplication()
|
||||
user := storage.User{ID: 12, Activated: true}
|
||||
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember}
|
||||
app.models.Tasks = &taskTestModel{items: map[int]storage.Task{90: {ID: 90, GardenID: 4, Title: "Fremd", Version: 1}}, nextID: 100}
|
||||
app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}}
|
||||
app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 8: {ID: 8, GardenID: 4, Name: "Fremd"}}}
|
||||
|
||||
foreignReference := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":9,"location_id":8}`))
|
||||
if foreignReference.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("foreign references: got %d; %s", foreignReference.Code, foreignReference.Body.String())
|
||||
}
|
||||
created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":5,"location_id":6,"priority":5}`))
|
||||
if created.Code != http.StatusCreated {
|
||||
t.Fatalf("create: got %d; %s", created.Code, created.Body.String())
|
||||
}
|
||||
value := app.models.Tasks.(*taskTestModel).items[101]
|
||||
if value.CreatedBy != user.ID || value.GardenID != 3 {
|
||||
t.Errorf("created task scope: %+v", value)
|
||||
}
|
||||
completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`))
|
||||
if completed.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].CompletedAt == nil {
|
||||
t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String())
|
||||
}
|
||||
cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"clear_plant_id":true,"clear_location_id":true}`))
|
||||
if cleared.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].PlantID != nil || app.models.Tasks.(*taskTestModel).items[101].LocationID != nil {
|
||||
t.Fatalf("clear references: got %d; %s", cleared.Code, cleared.Body.String())
|
||||
}
|
||||
foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/tasks/90", nil)
|
||||
if foreignRead.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign read: got %d", foreignRead.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
storage.ValidateEmail(v, input.Email)
|
||||
auth.ValidatePasswordPlaintext(v, input.Password)
|
||||
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
match, err := user.Password.Matches(input.Password)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !match {
|
||||
app.invalidCredentialsResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := app.models.Tokens.New(user.ID, 24*time.Hour, auth.ScopeAuthentication)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
if storage.ValidateEmail(v, input.Email); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
v.AddError("email", "no matching email address found")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Activated {
|
||||
v.AddError("email", "user account must be activated")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := app.models.Tokens.New(user.ID, 45*time.Minute, auth.ScopePasswordReset)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.background(func() {
|
||||
data := map[string]any{
|
||||
"passwordResetToken": token.Plaintext,
|
||||
}
|
||||
|
||||
err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data)
|
||||
if err != nil {
|
||||
app.logger.Error(err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
env := envelope{"message": "an email will be sent to you containing password reset instructions"}
|
||||
|
||||
err = app.writeJSON(w, http.StatusAccepted, env, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
if storage.ValidateEmail(v, input.Email); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetByEmail(input.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
v.AddError("email", "no matching email address found")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if user.Activated {
|
||||
v.AddError("email", "user has already been activated")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.background(func() {
|
||||
data := map[string]any{
|
||||
"activationToken": token.Plaintext,
|
||||
}
|
||||
|
||||
err := app.mailer.Send(user.Email, "token_activation.tmpl", data)
|
||||
if err != nil {
|
||||
app.logger.Error(err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
env := envelope{"message": "an email will be sent to you containing activation instructions"}
|
||||
|
||||
err = app.writeJSON(w, http.StatusAccepted, env, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := storage.User{
|
||||
Name: input.Name,
|
||||
Email: input.Email,
|
||||
Activated: false,
|
||||
}
|
||||
|
||||
err = user.Password.Set(input.Password)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
if storage.ValidateUser(v, user); !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err = app.models.Users.Insert(user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrDuplicateEmail):
|
||||
v.AddError("email", "a user with this email address already exists")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.background(func() {
|
||||
data := map[string]any{
|
||||
"activationToken": token.Plaintext,
|
||||
"userID": user.ID,
|
||||
}
|
||||
|
||||
err := app.mailer.Send(user.Email, "user_welcome.tmpl", data)
|
||||
if err != nil {
|
||||
app.logger.Error(err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
TokenPlaintext string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
auth.ValidateTokenPlaintext(v, input.TokenPlaintext)
|
||||
if input.Password != "" {
|
||||
auth.ValidatePasswordPlaintext(v, input.Password)
|
||||
}
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetForToken(auth.ScopeActivation, input.TokenPlaintext)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
v.AddError("token", "invalid or expired activation token")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
user.Activated = true
|
||||
if input.Password != "" {
|
||||
if err = user.Password.Set(input.Password); err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err = app.models.Users.Update(user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrEditConflict):
|
||||
app.editConflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Password string `json:"password"`
|
||||
TokenPlaintext string `json:"token"`
|
||||
}
|
||||
|
||||
err := app.readJSON(w, r, &input)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
v := validate.New()
|
||||
|
||||
auth.ValidatePasswordPlaintext(v, input.Password)
|
||||
auth.ValidateTokenPlaintext(v, input.TokenPlaintext)
|
||||
|
||||
if !v.Valid() {
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := app.models.Users.GetForToken(auth.ScopePasswordReset, input.TokenPlaintext)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrRecordNotFound):
|
||||
v.AddError("token", "invalid or expired password reset token")
|
||||
app.failedValidationResponse(w, r, v.Errors)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = user.Password.Set(input.Password)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err = app.models.Users.Update(user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrEditConflict):
|
||||
app.editConflictResponse(w, r)
|
||||
default:
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
env := envelope{"message": "your password was successfully reset"}
|
||||
|
||||
err = app.writeJSON(w, http.StatusOK, env, nil)
|
||||
if err != nil {
|
||||
app.serverErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package auth provides password hashing and secure token primitives used by
|
||||
// Gardomatic authentication flows.
|
||||
package auth
|
||||
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Password holds a bcrypt hash and, while constructing a new password, its
|
||||
// plaintext value for policy validation. Plaintext is never exposed.
|
||||
type Password struct {
|
||||
plaintext *string
|
||||
hash []byte
|
||||
}
|
||||
|
||||
// NewPassword reconstructs a password value from an existing bcrypt hash.
|
||||
func NewPassword(hash []byte) *Password {
|
||||
return &Password{hash: hash}
|
||||
}
|
||||
|
||||
// Set hashes plaintextPassword and replaces the stored hash.
|
||||
func (p *Password) Set(plaintextPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.plaintext = &plaintextPassword
|
||||
p.hash = hash
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a defensive copy of the bcrypt hash.
|
||||
func (p *Password) Get() []byte {
|
||||
return p.hash
|
||||
}
|
||||
|
||||
// Matches reports whether plaintextPassword matches the stored bcrypt hash.
|
||||
func (p *Password) Matches(plaintextPassword string) (bool, error) {
|
||||
err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Validate adds password-hash validation errors to v.
|
||||
func (p *Password) Validate(v *validate.Validator) {
|
||||
if p.plaintext != nil {
|
||||
ValidatePasswordPlaintext(v, *p.plaintext)
|
||||
}
|
||||
|
||||
if p.hash == nil {
|
||||
panic("missing password hash for user")
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePasswordPlaintext applies the password policy to plaintext input.
|
||||
func ValidatePasswordPlaintext(v *validate.Validator, plaintext string) {
|
||||
v.Check(plaintext != "", "password", "must be provided")
|
||||
v.Check(len(plaintext) >= 8, "password", "must be at least 8 bytes long")
|
||||
v.Check(len(plaintext) <= 72, "password", "must not be more than 72 bytes long")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
// ScopeActivation identifies account activation tokens.
|
||||
ScopeActivation = "activation"
|
||||
// ScopeAuthentication identifies bearer authentication tokens.
|
||||
ScopeAuthentication = "authentication"
|
||||
// ScopePasswordReset identifies password reset tokens.
|
||||
ScopePasswordReset = "password-reset"
|
||||
)
|
||||
|
||||
// Token carries a one-time plaintext token and the hash persisted by storage.
|
||||
type Token struct {
|
||||
Plaintext string `json:"token"`
|
||||
Hash []byte `json:"-"`
|
||||
UserID int `json:"-"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
Scope string `json:"-"`
|
||||
}
|
||||
|
||||
// NewToken creates a cryptographically random token for a user and scope.
|
||||
func NewToken(userID int, ttl time.Duration, scope string) Token {
|
||||
token := Token{
|
||||
Plaintext: rand.Text(),
|
||||
UserID: userID,
|
||||
Expiry: time.Now().Add(ttl),
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(token.Plaintext))
|
||||
token.Hash = hash[:]
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// Validate adds token consistency errors to v.
|
||||
func (tk Token) Validate(v *validate.Validator) {
|
||||
ValidateTokenPlaintext(v, tk.Plaintext)
|
||||
}
|
||||
|
||||
// ValidateTokenPlaintext checks the expected format of a user-supplied token.
|
||||
func ValidateTokenPlaintext(v *validate.Validator, tokenPlaintext string) {
|
||||
v.Check(tokenPlaintext != "", "token", "must be provided")
|
||||
v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package daterange resolves recurring calendar windows and normalized dates.
|
||||
package daterange
|
||||
|
||||
import "time"
|
||||
|
||||
// CalendarWindow resolves a recurring month/day range around now. A range whose
|
||||
// start month is after its end month crosses the year boundary.
|
||||
func CalendarWindow(now time.Time, monthFrom int, dayFrom *int, monthTo int, dayTo *int) (time.Time, time.Time) {
|
||||
location := now.Location()
|
||||
startYear := now.Year()
|
||||
if monthFrom > monthTo && int(now.Month()) <= monthTo {
|
||||
startYear--
|
||||
}
|
||||
endYear := startYear
|
||||
if monthFrom > monthTo {
|
||||
endYear++
|
||||
}
|
||||
startDay := 1
|
||||
if dayFrom != nil {
|
||||
startDay = clampDay(startYear, time.Month(monthFrom), *dayFrom)
|
||||
}
|
||||
endDay := daysInMonth(endYear, time.Month(monthTo))
|
||||
if dayTo != nil {
|
||||
endDay = clampDay(endYear, time.Month(monthTo), *dayTo)
|
||||
}
|
||||
return time.Date(startYear, time.Month(monthFrom), startDay, 0, 0, 0, 0, location), time.Date(endYear, time.Month(monthTo), endDay, 23, 59, 59, 0, location)
|
||||
}
|
||||
|
||||
// Date returns value at midnight in its original location.
|
||||
func Date(value time.Time) time.Time {
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
func clampDay(year int, month time.Month, day int) int {
|
||||
if maximum := daysInMonth(year, month); day > maximum {
|
||||
return maximum
|
||||
}
|
||||
if day < 1 {
|
||||
return 1
|
||||
}
|
||||
return day
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package daterange
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestCalendarWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
from int
|
||||
fromDay *int
|
||||
to int
|
||||
toDay *int
|
||||
wantStart, wantEnd string
|
||||
}{
|
||||
{"ordinary", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), 2, nil, 3, nil, "2026-02-01", "2026-03-31"},
|
||||
{"wrap before end", time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2025-11-01", "2026-02-28"},
|
||||
{"wrap before start", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2026-11-01", "2027-02-28"},
|
||||
{"clamps invalid month day", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 2, intPointer(31), 2, intPointer(31), "2026-02-28", "2026-02-28"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
start, end := CalendarWindow(test.now, test.from, test.fromDay, test.to, test.toDay)
|
||||
if got := start.Format("2006-01-02"); got != test.wantStart {
|
||||
t.Errorf("start=%s", got)
|
||||
}
|
||||
if got := end.Format("2006-01-02"); got != test.wantEnd {
|
||||
t.Errorf("end=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package mailer renders and delivers Gardomatic transactional email through
|
||||
// SMTP or an append-only development file.
|
||||
package mailer
|
||||
@@ -0,0 +1,163 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
|
||||
ht "html/template"
|
||||
tt "text/template"
|
||||
)
|
||||
|
||||
//go:embed "templates"
|
||||
var templateFS embed.FS
|
||||
|
||||
// Mailer renders embedded templates and delivers the resulting message.
|
||||
type Mailer struct {
|
||||
client *mail.Client
|
||||
mode Mode
|
||||
filePath string
|
||||
sender string
|
||||
fileMu sync.Mutex
|
||||
}
|
||||
|
||||
// Mode selects the delivery backend used by a Mailer.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
// ModeSMTP sends messages through an SMTP server.
|
||||
ModeSMTP Mode = "smtp"
|
||||
// ModeFile appends rendered messages to a local development file.
|
||||
ModeFile Mode = "file"
|
||||
)
|
||||
|
||||
// Config contains SMTP or development-file delivery settings.
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Sender string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// New validates config and creates a Mailer.
|
||||
func New(config Config) (*Mailer, error) {
|
||||
mailer := &Mailer{
|
||||
mode: config.Mode,
|
||||
filePath: config.FilePath,
|
||||
sender: config.Sender,
|
||||
}
|
||||
|
||||
switch config.Mode {
|
||||
case ModeSMTP:
|
||||
client, err := mail.NewClient(
|
||||
config.Host,
|
||||
mail.WithSMTPAuth(mail.SMTPAuthLogin),
|
||||
mail.WithPort(config.Port),
|
||||
mail.WithUsername(config.Username),
|
||||
mail.WithPassword(config.Password),
|
||||
mail.WithTimeout(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailer.client = client
|
||||
case ModeFile:
|
||||
if config.FilePath == "" {
|
||||
return nil, errors.New("mailer: file path must not be empty in file mode")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("mailer: unsupported mode %q", config.Mode)
|
||||
}
|
||||
|
||||
return mailer, nil
|
||||
}
|
||||
|
||||
// Send renders templateFile with data and delivers it to recipient.
|
||||
func (m *Mailer) Send(recipient string, templateFile string, data any) error {
|
||||
textTmpl, err := tt.New("").ParseFS(templateFS, "templates/"+templateFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := new(bytes.Buffer)
|
||||
err = textTmpl.ExecuteTemplate(subject, "subject", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plainBody := new(bytes.Buffer)
|
||||
err = textTmpl.ExecuteTemplate(plainBody, "plainBody", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
htmlBody := new(bytes.Buffer)
|
||||
err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
|
||||
err = msg.To(recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = msg.From(m.sender)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg.Subject(subject.String())
|
||||
msg.SetBodyString(mail.TypeTextPlain, plainBody.String())
|
||||
msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String())
|
||||
|
||||
if m.mode == ModeFile {
|
||||
return m.appendToFile(msg)
|
||||
}
|
||||
|
||||
return m.client.DialAndSend(msg)
|
||||
}
|
||||
|
||||
func (m *Mailer) appendToFile(msg *mail.Msg) error {
|
||||
var content bytes.Buffer
|
||||
if _, err := msg.WriteTo(&content); err != nil {
|
||||
return fmt.Errorf("mailer: format message: %w", err)
|
||||
}
|
||||
|
||||
m.fileMu.Lock()
|
||||
defer m.fileMu.Unlock()
|
||||
|
||||
file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: open output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err = file.WriteString("\n=== gardomatic mail ===\n"); err != nil {
|
||||
return fmt.Errorf("mailer: append separator: %w", err)
|
||||
}
|
||||
if _, err = content.WriteTo(file); err != nil {
|
||||
return fmt.Errorf("mailer: append message: %w", err)
|
||||
}
|
||||
if _, err = file.WriteString("\n"); err != nil {
|
||||
return fmt.Errorf("mailer: finish message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileModeAppendsMessages(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "mails.log")
|
||||
m, err := New(Config{
|
||||
Mode: ModeFile,
|
||||
Sender: "gardomatic@example.com",
|
||||
FilePath: filePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() returned an error: %v", err)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"userID": 42,
|
||||
"activationToken": "test-token",
|
||||
}
|
||||
for _, recipient := range []string{"alice@example.com", "bob@example.com"} {
|
||||
if err = m.Send(recipient, "user_welcome.tmpl", data); err != nil {
|
||||
t.Fatalf("Send() returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading mail output: %v", err)
|
||||
}
|
||||
output := string(content)
|
||||
|
||||
if got := strings.Count(output, "=== gardomatic mail ==="); got != 2 {
|
||||
t.Errorf("message count = %d, want 2", got)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"Subject: Welcome to Gardomatic!",
|
||||
"test-token",
|
||||
} {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("output does not contain %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileModeRequiresPath(t *testing.T) {
|
||||
_, err := New(Config{Mode: ModeFile, Sender: "gardomatic@example.com"})
|
||||
if err == nil {
|
||||
t.Fatal("New() returned no error without a file path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsUnknownMode(t *testing.T) {
|
||||
_, err := New(Config{Mode: "unknown", Sender: "gardomatic@example.com"})
|
||||
if err == nil {
|
||||
t.Fatal("New() returned no error for an unknown mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{define "subject"}}E-Mail-Adresse bei Gardomatic bestätigen{{end}}
|
||||
{{define "plainBody"}}Bestätige deine neue E-Mail-Adresse: {{.confirmationURL}}{{end}}
|
||||
{{define "htmlBody"}}<p>Bestätige deine neue E-Mail-Adresse:</p><p><a href="{{.confirmationURL}}">E-Mail-Adresse bestätigen</a></p>{{end}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{define "subject"}}Einladung zu Gardomatic{{end}}
|
||||
{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}}
|
||||
{{define "htmlBody"}}<p>Du wurdest zu einem Garten eingeladen.</p><p><a href="{{.inviteURL}}">Einladung annehmen</a></p>{{end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "subject"}}Gardomatic Testmail{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hallo,
|
||||
|
||||
diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.
|
||||
|
||||
Viele Grüße
|
||||
Gardomatic
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.</p>
|
||||
<p>Viele Grüße<br>Gardomatic</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{define "subject"}}Activate your Gardomatic account{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi,
|
||||
|
||||
{{if .activationURL}}Activate your account using this link:
|
||||
|
||||
{{.activationURL}}
|
||||
|
||||
Alternatively, enter this activation token on the activation page:
|
||||
{{.activationToken}}
|
||||
{{else}}Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account:
|
||||
|
||||
{"token": "{{.activationToken}}"}
|
||||
{{end}}
|
||||
|
||||
Please note that this is a one-time use token and it will expire in 3 days.
|
||||
|
||||
Thanks,
|
||||
|
||||
The Gardomatic Team
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi,</p>
|
||||
{{if .activationURL}}
|
||||
<p><a href="{{.activationURL}}">Activate your Gardomatic account</a></p>
|
||||
<p>Alternatively, enter this activation token on the activation page:</p>
|
||||
<pre><code>{{.activationToken}}</code></pre>
|
||||
{{else}}
|
||||
<p>Please send a <code>PUT /v1/users/activated</code> request with the following JSON body to activate your account:</p>
|
||||
<pre><code>
|
||||
{"token": "{{.activationToken}}"}
|
||||
</code></pre>
|
||||
{{end}}
|
||||
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>The Gardomatic Team</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "subject"}}Reset your Gardomatic password{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi,
|
||||
|
||||
Please send a `PUT /v1/users/password` request with the following JSON body to set a new password:
|
||||
|
||||
{"password": "your new password", "token": "{{.passwordResetToken}}"}
|
||||
|
||||
Please note that this is a one-time use token and it will expire in 45 minutes. If you need
|
||||
another token please make a `POST /v1/tokens/password-reset` request.
|
||||
|
||||
Thanks,
|
||||
|
||||
The Gardomatic Team
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi,</p>
|
||||
<p>Please send a <code>PUT /v1/users/password</code> request with the following JSON body to set a new password:</p>
|
||||
<pre><code>
|
||||
{"password": "your new password", "token": "{{.passwordResetToken}}"}
|
||||
</code></pre>
|
||||
<p>Please note that this is a one-time use token and it will expire in 45 minutes.
|
||||
If you need another token please make a <code>POST /v1/tokens/password-reset</code> request.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>The Gardomatic Team</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "subject"}}Einladung zu Gardomatic{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hallo {{.name}},
|
||||
|
||||
du wurdest zu Gardomatic eingeladen. Öffne den folgenden Link, um deinen Account zu aktivieren und ein Passwort festzulegen:
|
||||
|
||||
{{.activationURL}}
|
||||
|
||||
Der Link ist drei Tage lang gültig.
|
||||
|
||||
Viele Grüße
|
||||
Gardomatic
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<p>Hallo {{.name}},</p>
|
||||
<p>du wurdest zu Gardomatic eingeladen.</p>
|
||||
<p><a href="{{.activationURL}}">Account aktivieren und Passwort festlegen</a></p>
|
||||
<p>Der Link ist drei Tage lang gültig.</p>
|
||||
<p>Viele Grüße<br>Gardomatic</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{define "subject"}}Welcome to Gardomatic!{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi,
|
||||
|
||||
Thanks for signing up for a Gardomatic account. We're excited to have you on board!
|
||||
|
||||
For future reference, your user ID number is {{.userID}}.
|
||||
|
||||
{{if .activationURL}}Activate your account using this link:
|
||||
|
||||
{{.activationURL}}
|
||||
|
||||
Alternatively, enter this activation token on the activation page:
|
||||
{{.activationToken}}
|
||||
{{else}}Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON
|
||||
body to activate your account:
|
||||
|
||||
{"token": "{{.activationToken}}"}
|
||||
{{end}}
|
||||
|
||||
Please note that this is a one-time use token and it will expire in 3 days.
|
||||
|
||||
Thanks,
|
||||
|
||||
The Gardomatic Team
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p>Hi,</p>
|
||||
<p>Thanks for signing up for a Gardomatic account. We're excited to have you on board!</p>
|
||||
<p>For future reference, your user ID number is {{.userID}}.</p>
|
||||
{{if .activationURL}}
|
||||
<p><a href="{{.activationURL}}">Activate your Gardomatic account</a></p>
|
||||
<p>Alternatively, enter this activation token on the activation page:</p>
|
||||
<pre><code>{{.activationToken}}</code></pre>
|
||||
{{else}}
|
||||
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
|
||||
following JSON body to activate your account:</p>
|
||||
<pre><code>
|
||||
{"token": "{{.activationToken}}"}
|
||||
</code></pre>
|
||||
{{end}}
|
||||
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>The Gardomatic Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package environment provides strict parsing for runtime configuration.
|
||||
package environment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// String returns a trimmed environment value or fallback when it is empty.
|
||||
func String(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Required returns a non-empty environment value.
|
||||
func Required(name string) (string, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s must be set", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Int parses an integer environment value.
|
||||
func Int(name string, fallback int) (int, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// Float parses a floating-point environment value.
|
||||
func Float(name string, fallback float64) (float64, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a number: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// Bool parses a boolean environment value.
|
||||
func Bool(name string, fallback bool) (bool, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s must be a boolean: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// Duration parses a Go duration environment value.
|
||||
func Duration(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a duration: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// CSV returns non-empty, trimmed comma-separated values.
|
||||
func CSV(name string) []string {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParsers(t *testing.T) {
|
||||
t.Setenv("TEST_INT", "42")
|
||||
t.Setenv("TEST_FLOAT", "2.5")
|
||||
t.Setenv("TEST_BOOL", "true")
|
||||
t.Setenv("TEST_DURATION", "15m")
|
||||
t.Setenv("TEST_CSV", " one, two ,,three ")
|
||||
|
||||
if value, err := Int("TEST_INT", 0); err != nil || value != 42 {
|
||||
t.Fatalf("Int() = %d, %v", value, err)
|
||||
}
|
||||
if value, err := Float("TEST_FLOAT", 0); err != nil || value != 2.5 {
|
||||
t.Fatalf("Float() = %f, %v", value, err)
|
||||
}
|
||||
if value, err := Bool("TEST_BOOL", false); err != nil || !value {
|
||||
t.Fatalf("Bool() = %t, %v", value, err)
|
||||
}
|
||||
if value, err := Duration("TEST_DURATION", 0); err != nil || value != 15*time.Minute {
|
||||
t.Fatalf("Duration() = %s, %v", value, err)
|
||||
}
|
||||
values := CSV("TEST_CSV")
|
||||
if len(values) != 3 || values[1] != "two" {
|
||||
t.Fatalf("CSV() = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidValues(t *testing.T) {
|
||||
t.Setenv("TEST_VALUE", "not-valid")
|
||||
if _, err := Int("TEST_VALUE", 0); err == nil {
|
||||
t.Fatal("Int() did not return an error")
|
||||
}
|
||||
if _, err := Bool("TEST_VALUE", false); err == nil {
|
||||
t.Fatal("Bool() did not return an error")
|
||||
}
|
||||
if _, err := Duration("TEST_VALUE", 0); err == nil {
|
||||
t.Fatal("Duration() did not return an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package validate contains reusable validation helpers and an error collector
|
||||
// for API and form inputs.
|
||||
package validate
|
||||
@@ -0,0 +1,44 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// PermittedValue reports whether value appears in permittedValues.
|
||||
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
|
||||
return slices.Contains(permittedValues, value)
|
||||
}
|
||||
|
||||
// Matches reports whether value satisfies rx.
|
||||
func Matches(value string, rx *regexp.Regexp) bool {
|
||||
return rx.MatchString(value)
|
||||
}
|
||||
|
||||
// Unique reports whether values contains no duplicate elements.
|
||||
func Unique[T comparable](values []T) bool {
|
||||
uniqueValues := make(map[T]bool)
|
||||
|
||||
for _, value := range values {
|
||||
uniqueValues[value] = true
|
||||
}
|
||||
|
||||
return len(values) == len(uniqueValues)
|
||||
}
|
||||
|
||||
// NotBlank reports whether value contains non-whitespace characters.
|
||||
func NotBlank(value string) bool {
|
||||
return strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
// MaxChars reports whether value contains at most n Unicode code points.
|
||||
func MaxChars(value string, n int) bool {
|
||||
return utf8.RuneCountInString(value) <= n
|
||||
}
|
||||
|
||||
// MinChars reports whether value contains at least n Unicode code points.
|
||||
func MinChars(value string, n int) bool {
|
||||
return utf8.RuneCountInString(value) >= n
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
// EmailRX matches the email-address format accepted by Gardomatic.
|
||||
EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
// ColorRX matches the hexadecimal RGB colors accepted for user colors.
|
||||
ColorRX = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
)
|
||||
|
||||
// Validator collects validation failures keyed by input field.
|
||||
type Validator struct {
|
||||
Errors map[string]string
|
||||
NonFieldErrors []string
|
||||
FieldErrors map[string]string
|
||||
}
|
||||
|
||||
// New returns an empty Validator.
|
||||
func New() *Validator {
|
||||
return &Validator{Errors: make(map[string]string)}
|
||||
}
|
||||
|
||||
// Valid reports whether no validation errors have been recorded.
|
||||
func (v *Validator) Valid() bool {
|
||||
return len(v.Errors) == 0 && len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
|
||||
}
|
||||
|
||||
// AddError records message for key unless key already has an error.
|
||||
func (v *Validator) AddError(key, message string) {
|
||||
if _, exists := v.Errors[key]; !exists {
|
||||
v.Errors[key] = message
|
||||
}
|
||||
}
|
||||
|
||||
// Check records message for key when ok is false.
|
||||
func (v *Validator) Check(ok bool, key, message string) {
|
||||
if !ok {
|
||||
v.AddError(key, message)
|
||||
}
|
||||
}
|
||||
|
||||
// AddNonFieldError records an error that is not associated with one field.
|
||||
func (v *Validator) AddNonFieldError(message string) {
|
||||
v.NonFieldErrors = append(v.NonFieldErrors, message)
|
||||
}
|
||||
|
||||
// AddFieldError records a form-specific field error.
|
||||
func (v *Validator) AddFieldError(key, message string) {
|
||||
|
||||
if v.FieldErrors == nil {
|
||||
v.FieldErrors = make(map[string]string)
|
||||
}
|
||||
|
||||
if _, exists := v.FieldErrors[key]; !exists {
|
||||
v.FieldErrors[key] = message
|
||||
}
|
||||
}
|
||||
|
||||
// CheckField records a form-specific field error when ok is false.
|
||||
func (v *Validator) CheckField(ok bool, key, message string) {
|
||||
if !ok {
|
||||
v.AddFieldError(key, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// ApplicationSettings contains application-wide automation settings.
|
||||
type ApplicationSettings struct {
|
||||
LifecycleStatusEnabled bool `json:"lifecycle_status_enabled"`
|
||||
LifecycleRemovalMonth int `json:"lifecycle_removal_month"`
|
||||
LifecycleRemovalDay int `json:"lifecycle_removal_day"`
|
||||
Timezone string `json:"timezone"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ApplicationSettingsModelInterface persists global settings and applies lifecycle cleanup atomically.
|
||||
type ApplicationSettingsModelInterface interface {
|
||||
Get() (ApplicationSettings, error)
|
||||
Update(settings ApplicationSettings) (ApplicationSettings, error)
|
||||
RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error)
|
||||
}
|
||||
|
||||
// ValidateApplicationSettings validates global automation configuration.
|
||||
func ValidateApplicationSettings(v *validate.Validator, settings ApplicationSettings) {
|
||||
v.Check(settings.LifecycleRemovalMonth >= 1 && settings.LifecycleRemovalMonth <= 12, "lifecycle_removal_month", "must be between 1 and 12")
|
||||
v.Check(settings.LifecycleRemovalDay >= 1 && settings.LifecycleRemovalDay <= 31, "lifecycle_removal_day", "must be between 1 and 31")
|
||||
v.Check(strings.TrimSpace(settings.Timezone) != "", "timezone", "must be provided")
|
||||
if strings.TrimSpace(settings.Timezone) != "" {
|
||||
_, err := time.LoadLocation(settings.Timezone)
|
||||
v.Check(err == nil, "timezone", "must be a valid IANA timezone")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CareInstructionModelInterface persists care instructions within their
|
||||
// species and garden boundary.
|
||||
type CareInstructionModelInterface interface {
|
||||
Insert(CareInstruction) (CareInstruction, error)
|
||||
Get(gardenID, speciesID, id int) (CareInstruction, error)
|
||||
GetAllForSpecies(gardenID, speciesID int) ([]CareInstruction, error)
|
||||
Update(gardenID int, instruction CareInstruction) (CareInstruction, error)
|
||||
Delete(gardenID, speciesID, id int) error
|
||||
}
|
||||
|
||||
// CareInstruction records garden-specific cultivation knowledge for a species.
|
||||
type CareInstruction struct {
|
||||
ID int `json:"id"`
|
||||
SpeciesID int `json:"species_id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ValidateCareInstruction applies the care-instruction input rules.
|
||||
func ValidateCareInstruction(v *validate.Validator, item CareInstruction) {
|
||||
v.Check(strings.TrimSpace(item.Text) != "", "text", "must be provided")
|
||||
v.Check(len(item.Text) <= 10000, "text", "must not be more than 10000 bytes long")
|
||||
v.Check(validate.PermittedValue(item.Status, "good", "bad", "untested", "testing", "planned"), "status", "is invalid")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package storage defines Gardomatic persistence models and the interfaces
|
||||
// implemented by database-specific adapters.
|
||||
package storage
|
||||
@@ -0,0 +1,10 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDuplicateEmail indicates that a user email is already registered.
|
||||
ErrDuplicateEmail = errors.New("models: duplicate email")
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
//lint:file-ignore U1000 pagination helpers are retained for the upcoming filtered list endpoints
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// Filters contains bounded pagination and safe-list-based sorting parameters.
|
||||
type Filters struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Sort string
|
||||
SortSafelist []string
|
||||
}
|
||||
|
||||
func (f Filters) sortColumn() string {
|
||||
if slices.Contains(f.SortSafelist, f.Sort) {
|
||||
return strings.TrimPrefix(f.Sort, "-")
|
||||
}
|
||||
|
||||
panic("unsafe sort parameter: " + f.Sort)
|
||||
}
|
||||
|
||||
func (f Filters) sortDirection() string {
|
||||
if strings.HasPrefix(f.Sort, "-") {
|
||||
return "DESC"
|
||||
}
|
||||
|
||||
return "ASC"
|
||||
}
|
||||
|
||||
func (f Filters) limit() int {
|
||||
return f.PageSize
|
||||
}
|
||||
|
||||
func (f Filters) offset() int {
|
||||
return (f.Page - 1) * f.PageSize
|
||||
}
|
||||
|
||||
// ValidateFilters checks pagination bounds and the requested sort field.
|
||||
func ValidateFilters(v *validate.Validator, f Filters) {
|
||||
v.Check(f.Page > 0, "page", "must be greater than zero")
|
||||
v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million")
|
||||
v.Check(f.PageSize > 0, "page_size", "must be greater than zero")
|
||||
v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100")
|
||||
|
||||
v.Check(validate.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value")
|
||||
}
|
||||
|
||||
// Metadata describes a page within a filtered result set.
|
||||
type Metadata struct {
|
||||
CurrentPage int `json:"current_page,omitzero"`
|
||||
PageSize int `json:"page_size,omitzero"`
|
||||
FirstPage int `json:"first_page,omitzero"`
|
||||
LastPage int `json:"last_page,omitzero"`
|
||||
TotalRecords int `json:"total_records,omitzero"`
|
||||
}
|
||||
|
||||
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
|
||||
if totalRecords == 0 {
|
||||
|
||||
return Metadata{}
|
||||
}
|
||||
|
||||
return Metadata{
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
FirstPage: 1,
|
||||
LastPage: (totalRecords + pageSize - 1) / pageSize,
|
||||
TotalRecords: totalRecords,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// GardenInviteModelInterface persists pending garden invitations.
|
||||
type GardenInviteModelInterface interface {
|
||||
Upsert(invite GardenInvite) (GardenInvite, error)
|
||||
GetByToken(tokenPlaintext string) (GardenInvite, error)
|
||||
GetAllForGarden(gardenID int) ([]GardenInvite, error)
|
||||
Delete(gardenID, inviteID int) error
|
||||
Accept(tokenPlaintext string, user User) (GardenMember, error)
|
||||
}
|
||||
|
||||
// GardenInvite grants a user identified by email a garden role.
|
||||
type GardenInvite struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
Email string `json:"email"`
|
||||
Role GardenRole `json:"role"`
|
||||
Token string `json:"token,omitempty"`
|
||||
InvitedBy int `json:"invited_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GardenRole identifies a member's authorization level within one garden.
|
||||
type GardenRole string
|
||||
|
||||
// GardenPermission identifies one garden-scoped capability.
|
||||
type GardenPermission string
|
||||
|
||||
// Garden permission constants identify capabilities resolved by the API's
|
||||
// garden authorization middleware.
|
||||
const (
|
||||
GardenPermissionGardenRead GardenPermission = "garden:read"
|
||||
GardenPermissionGardenUpdate GardenPermission = "garden:update"
|
||||
GardenPermissionGardenDelete GardenPermission = "garden:delete"
|
||||
// GardenPermissionContentWrite is retained for garden content which has not
|
||||
// yet been split into object-specific permissions (journal and assignments).
|
||||
GardenPermissionContentWrite GardenPermission = "content:write"
|
||||
GardenPermissionMembersWrite GardenPermission = "members:write"
|
||||
GardenPermissionSpeciesWrite GardenPermission = "species:write"
|
||||
|
||||
GardenPermissionPlantCreate GardenPermission = "plants:create"
|
||||
GardenPermissionPlantReadOwn GardenPermission = "plants:read:own"
|
||||
GardenPermissionPlantReadOther GardenPermission = "plants:read:other"
|
||||
GardenPermissionPlantUpdateOwn GardenPermission = "plants:update:own"
|
||||
GardenPermissionPlantUpdateOther GardenPermission = "plants:update:other"
|
||||
GardenPermissionPlantDeleteOwn GardenPermission = "plants:delete:own"
|
||||
GardenPermissionPlantDeleteOther GardenPermission = "plants:delete:other"
|
||||
|
||||
GardenPermissionLocationCreate GardenPermission = "locations:create"
|
||||
GardenPermissionLocationReadOwn GardenPermission = "locations:read:own"
|
||||
GardenPermissionLocationReadOther GardenPermission = "locations:read:other"
|
||||
GardenPermissionLocationUpdateOwn GardenPermission = "locations:update:own"
|
||||
GardenPermissionLocationUpdateOther GardenPermission = "locations:update:other"
|
||||
GardenPermissionLocationDeleteOwn GardenPermission = "locations:delete:own"
|
||||
GardenPermissionLocationDeleteOther GardenPermission = "locations:delete:other"
|
||||
|
||||
GardenPermissionTaskCreate GardenPermission = "tasks:create"
|
||||
GardenPermissionTaskReadOwn GardenPermission = "tasks:read:own"
|
||||
GardenPermissionTaskReadOther GardenPermission = "tasks:read:other"
|
||||
GardenPermissionTaskUpdateOwn GardenPermission = "tasks:update:own"
|
||||
GardenPermissionTaskUpdateOther GardenPermission = "tasks:update:other"
|
||||
GardenPermissionTaskDeleteOwn GardenPermission = "tasks:delete:own"
|
||||
GardenPermissionTaskDeleteOther GardenPermission = "tasks:delete:other"
|
||||
GardenPermissionTaskCompleteOwn GardenPermission = "tasks:complete:own"
|
||||
GardenPermissionTaskCompleteOther GardenPermission = "tasks:complete:other"
|
||||
)
|
||||
|
||||
const (
|
||||
// GardenRoleOwner grants full control over a garden.
|
||||
GardenRoleOwner GardenRole = "owner"
|
||||
// GardenRoleAdmin grants administrative access without ownership.
|
||||
GardenRoleAdmin GardenRole = "admin"
|
||||
// GardenRoleMember grants ordinary editing access.
|
||||
GardenRoleMember GardenRole = "member"
|
||||
// GardenRoleViewer grants read-only access.
|
||||
GardenRoleViewer GardenRole = "viewer"
|
||||
// GardenRoleWorker may read and complete tasks, but cannot otherwise edit content.
|
||||
GardenRoleWorker GardenRole = "worker"
|
||||
)
|
||||
|
||||
// Can reports whether a role grants permission.
|
||||
func (role GardenRole) Can(permission GardenPermission) bool {
|
||||
switch role {
|
||||
case GardenRoleOwner:
|
||||
return validGardenPermission(permission)
|
||||
case GardenRoleAdmin:
|
||||
return validGardenPermission(permission) && permission != GardenPermissionGardenDelete
|
||||
case GardenRoleMember:
|
||||
switch permission {
|
||||
case GardenPermissionGardenRead,
|
||||
GardenPermissionContentWrite,
|
||||
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantDeleteOwn,
|
||||
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationDeleteOwn,
|
||||
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskDeleteOwn, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case GardenRoleViewer:
|
||||
return permission == GardenPermissionGardenRead ||
|
||||
permission == GardenPermissionPlantReadOwn || permission == GardenPermissionPlantReadOther ||
|
||||
permission == GardenPermissionLocationReadOwn || permission == GardenPermissionLocationReadOther ||
|
||||
permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther
|
||||
case GardenRoleWorker:
|
||||
return permission == GardenPermissionGardenRead ||
|
||||
permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther ||
|
||||
permission == GardenPermissionTaskCompleteOwn || permission == GardenPermissionTaskCompleteOther
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validGardenPermission(permission GardenPermission) bool {
|
||||
switch permission {
|
||||
case GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete,
|
||||
GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite,
|
||||
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther,
|
||||
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther,
|
||||
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidGardenPermission reports whether permission is a known garden-scoped
|
||||
// permission or wildcard.
|
||||
func ValidGardenPermission(permission string) bool {
|
||||
return permission == "*" || permission == "garden:*" || validGardenPermission(GardenPermission(permission))
|
||||
}
|
||||
|
||||
// ResolveGardenPermissions applies garden-specific grants and revocations to a
|
||||
// role's base permissions. The result is deduplicated and stable-sorted.
|
||||
func ResolveGardenPermissions(base []string, overrides []GardenRolePermissionOverride) []GardenPermission {
|
||||
permissions := make(map[GardenPermission]bool)
|
||||
apply := func(permission string, granted bool) {
|
||||
if permission == "*" || permission == "garden:*" {
|
||||
for _, concrete := range AllGardenPermissions() {
|
||||
if permission == "garden:*" && concrete == GardenPermissionGardenDelete {
|
||||
continue
|
||||
}
|
||||
permissions[concrete] = granted
|
||||
}
|
||||
return
|
||||
}
|
||||
permissions[GardenPermission(permission)] = granted
|
||||
}
|
||||
for _, permission := range base {
|
||||
apply(permission, true)
|
||||
}
|
||||
for _, override := range overrides {
|
||||
apply(override.Permission, override.Granted)
|
||||
}
|
||||
result := make([]GardenPermission, 0, len(permissions))
|
||||
for permission, granted := range permissions {
|
||||
if granted {
|
||||
result = append(result, permission)
|
||||
}
|
||||
}
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Permissions returns the concrete capabilities bundled into a garden role.
|
||||
func (role GardenRole) Permissions() []GardenPermission {
|
||||
permissions := []GardenPermission{}
|
||||
for _, permission := range AllGardenPermissions() {
|
||||
if role.Can(permission) {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
// AllGardenPermissions returns every concrete garden-scoped permission.
|
||||
func AllGardenPermissions() []GardenPermission {
|
||||
return []GardenPermission{
|
||||
GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete,
|
||||
GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite,
|
||||
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther,
|
||||
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther,
|
||||
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther,
|
||||
}
|
||||
}
|
||||
|
||||
// GardenMemberModelInterface persists garden membership and role assignments.
|
||||
type GardenMemberModelInterface interface {
|
||||
Insert(member GardenMember) (GardenMember, error)
|
||||
Get(gardenID, userID int) (GardenMember, error)
|
||||
GetAllForGarden(gardenID int) ([]GardenMember, error)
|
||||
Update(member GardenMember) (GardenMember, error)
|
||||
Delete(gardenID, userID int) error
|
||||
TransferOwnership(gardenID, fromUserID, toUserID int) error
|
||||
}
|
||||
|
||||
// GardenMember links a user to a garden with a role.
|
||||
type GardenMember struct {
|
||||
GardenID int `json:"garden_id"`
|
||||
UserID int `json:"user_id"`
|
||||
Role GardenRole `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Permissions []GardenPermission `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// Can uses persisted permissions when present, retaining built-in roles for
|
||||
// compatibility with in-memory tests and pre-migration callers.
|
||||
func (member GardenMember) Can(permission GardenPermission) bool {
|
||||
if member.Permissions == nil {
|
||||
return member.Role.Can(permission)
|
||||
}
|
||||
for _, granted := range member.Permissions {
|
||||
if granted == permission || granted == "*" || granted == "garden:*" && permission != GardenPermissionGardenDelete {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// GardenModelInterface persists gardens and their initial owner membership.
|
||||
type GardenModelInterface interface {
|
||||
Insert(garden Garden, ownerID int) (Garden, error)
|
||||
Get(id int) (Garden, error)
|
||||
GetAllForUser(userID int) ([]Garden, error)
|
||||
Update(garden Garden) (Garden, error)
|
||||
Delete(id int) error
|
||||
}
|
||||
|
||||
// Garden is the tenant boundary for garden-specific resources.
|
||||
type Garden struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Role GardenRole `json:"role,omitempty"`
|
||||
Permissions []GardenPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ValidateGarden applies persistence-independent garden validation rules.
|
||||
func ValidateGarden(v *validate.Validator, garden Garden) {
|
||||
v.Check(strings.TrimSpace(garden.Name) != "", "name", "must be provided")
|
||||
v.Check(len(garden.Name) <= 500, "name", "must not be more than 500 bytes long")
|
||||
v.Check(len(garden.Description) <= 5000, "description", "must not be more than 5000 bytes long")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// MaxImageSize is the largest image payload accepted by storage, in bytes.
|
||||
const MaxImageSize = 10 << 20
|
||||
|
||||
// ImageModelInterface persists garden-owned image data and assignment history.
|
||||
type ImageModelInterface interface {
|
||||
Insert(image Image) (Image, error)
|
||||
Get(gardenID, id int) (Image, error)
|
||||
GetAllForGarden(gardenID int, filter ImageFilter) ([]Image, error)
|
||||
CountForGarden(gardenID int) (int, error)
|
||||
RecordAssignment(change ImageAssignment) error
|
||||
}
|
||||
|
||||
// Image is a binary image stored in a garden's reusable media library.
|
||||
type Image struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
FileName string `json:"file_name"`
|
||||
MediaType string `json:"media_type"`
|
||||
Size int64 `json:"size"`
|
||||
Source string `json:"source"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
// ImageFilter restricts image-library queries by filename or media type.
|
||||
type ImageFilter struct {
|
||||
Source string
|
||||
Query string
|
||||
}
|
||||
|
||||
// ImageAssignment records an image change on a garden entity.
|
||||
type ImageAssignment struct {
|
||||
GardenID int
|
||||
EntityType string
|
||||
EntityID int
|
||||
PreviousImageID *int
|
||||
ImageID *int
|
||||
ChangedBy int
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// MaxJournalAttachmentSize is the largest journal attachment accepted, in bytes.
|
||||
const MaxJournalAttachmentSize = 25 << 20
|
||||
|
||||
// JournalModelInterface persists journal entries and their attachments.
|
||||
type JournalModelInterface interface {
|
||||
Insert(entry JournalEntry) (JournalEntry, error)
|
||||
Get(gardenID, id int) (JournalEntry, error)
|
||||
GetAllForGarden(gardenID int, entryType JournalEntryType) ([]JournalEntry, error)
|
||||
Update(gardenID int, entry JournalEntry) (JournalEntry, error)
|
||||
Delete(gardenID, id int) error
|
||||
InsertAttachment(gardenID, entryID int, attachment JournalAttachment) (JournalAttachment, error)
|
||||
GetAttachment(gardenID, entryID, attachmentID int) (JournalAttachment, error)
|
||||
DeleteAttachment(gardenID, entryID, attachmentID int) error
|
||||
}
|
||||
|
||||
// JournalEntryType distinguishes chronological journal entries from pinboard
|
||||
// notes while sharing the same persistence model.
|
||||
type JournalEntryType string
|
||||
|
||||
// Supported journal entry types.
|
||||
const (
|
||||
JournalEntryTypeJournal JournalEntryType = "journal"
|
||||
JournalEntryTypePinboard JournalEntryType = "pinboard"
|
||||
)
|
||||
|
||||
// JournalEntry is a garden note with optional tags and attachments.
|
||||
type JournalEntry struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
AuthorID int `json:"author_id"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorColor string `json:"author_color"`
|
||||
EntryType JournalEntryType `json:"entry_type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Attachments []JournalAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// JournalAttachment contains either inline binary data or a reference to a
|
||||
// reusable image-library item.
|
||||
type JournalAttachment struct {
|
||||
ID int `json:"id"`
|
||||
EntryID int `json:"entry_id"`
|
||||
FileName string `json:"file_name"`
|
||||
MediaType string `json:"media_type"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Data []byte `json:"-"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateJournalEntry applies the journal-entry input rules.
|
||||
func ValidateJournalEntry(v *validate.Validator, entry JournalEntry) {
|
||||
entryType := entry.EntryType
|
||||
if entryType == "" {
|
||||
entryType = JournalEntryTypeJournal
|
||||
}
|
||||
v.Check(validate.PermittedValue(entryType, JournalEntryTypeJournal, JournalEntryTypePinboard), "entry_type", "must be journal or pinboard")
|
||||
if entryType == JournalEntryTypeJournal {
|
||||
v.Check(strings.TrimSpace(entry.Title) != "", "title", "must be provided")
|
||||
}
|
||||
v.Check(len(entry.Title) <= 500, "title", "must not be more than 500 bytes long")
|
||||
v.Check(len(entry.Body) <= 100_000, "body", "must not be more than 100000 bytes long")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
func TestValidateJournalEntry(t *testing.T) {
|
||||
valid := validate.New()
|
||||
ValidateJournalEntry(valid, JournalEntry{Title: "Erste Ernte", Body: "**Drei** Tomaten geerntet."})
|
||||
if !valid.Valid() {
|
||||
t.Fatalf("valid entry rejected: %#v", valid.Errors)
|
||||
}
|
||||
|
||||
invalid := validate.New()
|
||||
ValidateJournalEntry(invalid, JournalEntry{Title: " ", Body: strings.Repeat("x", 100_001)})
|
||||
if invalid.Errors["title"] == "" || invalid.Errors["body"] == "" {
|
||||
t.Fatalf("expected title and body errors, got %#v", invalid.Errors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// LocationModelInterface persists hierarchical locations within a garden.
|
||||
type LocationModelInterface interface {
|
||||
Insert(location Location) (Location, error)
|
||||
Get(gardenID, id int) (Location, error)
|
||||
GetAllForGarden(gardenID int) ([]Location, error)
|
||||
Update(gardenID int, location Location) (Location, error)
|
||||
Delete(gardenID, id int) error
|
||||
}
|
||||
|
||||
// ValidateLocation applies persistence-independent location validation rules.
|
||||
func ValidateLocation(v *validate.Validator, location Location) {
|
||||
v.Check(strings.TrimSpace(location.Name) != "", "name", "must be provided")
|
||||
v.Check(len(location.Name) <= 500, "name", "must not be more than 500 bytes long")
|
||||
v.Check(len(location.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
|
||||
v.Check(len(location.Kind) <= 100, "kind", "must not be more than 100 bytes long")
|
||||
v.Check(len(location.Attributes) == 0 || json.Valid(location.Attributes), "attributes", "must be valid JSON")
|
||||
if location.ParentID != nil {
|
||||
v.Check(*location.ParentID > 0, "parent_id", "must be a positive integer")
|
||||
v.Check(*location.ParentID != location.ID, "parent_id", "must not refer to the location itself")
|
||||
}
|
||||
if location.AreaSQM != nil {
|
||||
v.Check(*location.AreaSQM >= 0, "area_sqm", "must be zero or greater")
|
||||
}
|
||||
validateOptionalEnum(v, "sun_exposure", location.SunExposure, "sunny", "partial_shade", "shade")
|
||||
validateOptionalEnum(v, "soil_condition", location.SoilCondition, "dry", "moist", "boggy")
|
||||
validateOptionalEnum(v, "soil_reaction", location.SoilReaction, "alkaline", "acidic", "neutral")
|
||||
}
|
||||
|
||||
// Location describes a physical place where plants can be assigned.
|
||||
type Location struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
ParentID *int `json:"parent_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
AreaSQM *float64 `json:"area_sqm,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRecordNotFound indicates that a scoped query matched no record.
|
||||
ErrRecordNotFound = errors.New("record not found")
|
||||
// ErrEditConflict indicates a failed optimistic-lock update.
|
||||
ErrEditConflict = errors.New("edit conflict")
|
||||
// ErrConflict indicates a uniqueness or equivalent persistence conflict.
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
// Models groups the persistence interfaces required by the API application.
|
||||
type Models struct {
|
||||
ApplicationSettings ApplicationSettingsModelInterface
|
||||
Roles RoleModelInterface
|
||||
Gardens GardenModelInterface
|
||||
GardenMembers GardenMemberModelInterface
|
||||
GardenInvites GardenInviteModelInterface
|
||||
Locations LocationModelInterface
|
||||
Plants PlantModelInterface
|
||||
PlantLocations PlantLocationModelInterface
|
||||
Species SpeciesModelInterface
|
||||
CareInstructions CareInstructionModelInterface
|
||||
SpeciesCategories SpeciesCategoryModelInterface
|
||||
TaskPriorities TaskPriorityModelInterface
|
||||
SpeciesTaskTemplates SpeciesTaskTemplateModelInterface
|
||||
Tasks TaskModelInterface
|
||||
Journal JournalModelInterface
|
||||
Images ImageModelInterface
|
||||
Tags TagModelInterface
|
||||
TaskTemplateOptOuts TaskTemplateOptOutModelInterface
|
||||
Tokens TokenModelInterface
|
||||
Users UserModelInterface
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// PlantLocationModelInterface persists assignments between plants and locations.
|
||||
type PlantLocationModelInterface interface {
|
||||
Insert(gardenID int, plantLocation PlantLocation) (PlantLocation, error)
|
||||
Get(gardenID, id int) (PlantLocation, error)
|
||||
GetAllForPlant(gardenID, plantID int) ([]PlantLocation, error)
|
||||
GetAllForLocation(gardenID, locationID int) ([]PlantLocation, error)
|
||||
Update(gardenID int, plantLocation PlantLocation) (PlantLocation, error)
|
||||
Delete(gardenID, id int) error
|
||||
}
|
||||
|
||||
// PlantLocation records where and when a quantity of plants was planted.
|
||||
type PlantLocation struct {
|
||||
ID int `json:"id"`
|
||||
PlantID int `json:"plant_id"`
|
||||
LocationID int `json:"location_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
PlantedAt *time.Time `json:"planted_at,omitempty"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ValidatePlantLocation applies persistence-independent assignment validation rules.
|
||||
func ValidatePlantLocation(v *validate.Validator, assignment PlantLocation) {
|
||||
v.Check(assignment.PlantID > 0, "plant_id", "must be a positive integer")
|
||||
v.Check(assignment.LocationID > 0, "location_id", "must be a positive integer")
|
||||
v.Check(assignment.Quantity > 0, "quantity", "must be greater than zero")
|
||||
v.Check(len(strings.TrimSpace(assignment.Notes)) <= 10_000, "notes", "must not be more than 10000 bytes long")
|
||||
if assignment.PlantedAt != nil && assignment.RemovedAt != nil {
|
||||
v.Check(!assignment.RemovedAt.Before(*assignment.PlantedAt), "removed_at", "must not be before planted_at")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
// PlantModelInterface persists plant instances within a garden boundary.
|
||||
type PlantModelInterface interface {
|
||||
Insert(plant Plant) (Plant, error)
|
||||
Get(gardenID, id int) (Plant, error)
|
||||
GetAllForGarden(gardenID int) ([]Plant, error)
|
||||
Update(gardenID int, plant Plant) (Plant, error)
|
||||
Delete(gardenID, id int) error
|
||||
}
|
||||
|
||||
// Plant represents a named plant instance managed by a garden.
|
||||
type Plant struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
SpeciesID *int `json:"species_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
AcquiredAt *time.Time `json:"acquired_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
PlantedBy int `json:"planted_by"`
|
||||
PlantedByName string `json:"planted_by_name,omitempty"`
|
||||
}
|
||||
|
||||
// ValidatePlant applies persistence-independent plant validation rules.
|
||||
func ValidatePlant(v *validate.Validator, plant Plant) {
|
||||
v.Check(strings.TrimSpace(plant.Name) != "", "name", "must be provided")
|
||||
v.Check(len(plant.Name) <= 500, "name", "must not be more than 500 bytes long")
|
||||
v.Check(len(plant.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long")
|
||||
v.Check(validate.PermittedValue(plant.Status, "alive", "dead", "removed", "infested", "harvested"), "status", "must be alive, dead, removed, infested or harvested")
|
||||
v.Check(len(plant.Attributes) == 0 || json.Valid(plant.Attributes), "attributes", "must be valid JSON")
|
||||
ValidateTags(v, plant.Tags)
|
||||
if plant.SpeciesID != nil {
|
||||
v.Check(*plant.SpeciesID > 0, "species_id", "must be a positive integer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ApplicationSettingsModel stores global automation configuration.
|
||||
// ApplicationSettingsModel persists the singleton application configuration
|
||||
// and performs lifecycle cleanup transactionally.
|
||||
type ApplicationSettingsModel struct{ DB *sql.DB }
|
||||
|
||||
// Get returns the singleton application settings.
|
||||
func (m ApplicationSettingsModel) Get() (storage.ApplicationSettings, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var settings storage.ApplicationSettings
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
SELECT lifecycle_status_enabled, lifecycle_removal_month, lifecycle_removal_day,
|
||||
timezone, updated_at, version
|
||||
FROM application_settings WHERE singleton = true`).Scan(
|
||||
&settings.LifecycleStatusEnabled, &settings.LifecycleRemovalMonth,
|
||||
&settings.LifecycleRemovalDay, &settings.Timezone, &settings.UpdatedAt, &settings.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.ApplicationSettings{}, recordError(err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Update replaces the singleton application settings using optimistic locking.
|
||||
func (m ApplicationSettingsModel) Update(settings storage.ApplicationSettings) (storage.ApplicationSettings, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE application_settings
|
||||
SET lifecycle_status_enabled = $1, lifecycle_removal_month = $2,
|
||||
lifecycle_removal_day = $3, timezone = $4,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE singleton = true AND version = $5
|
||||
RETURNING updated_at, version`,
|
||||
settings.LifecycleStatusEnabled, settings.LifecycleRemovalMonth,
|
||||
settings.LifecycleRemovalDay, settings.Timezone, settings.Version,
|
||||
).Scan(&settings.UpdatedAt, &settings.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.ApplicationSettings{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.ApplicationSettings{}, recordError(err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// RemoveExpiredPlants closes expired annual and biennial plants together with
|
||||
// their active placements and records each status transition atomically.
|
||||
func (m ApplicationSettingsModel) RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
UPDATE plants p
|
||||
SET status = 'removed', removed_at = $1, updated_at = CURRENT_TIMESTAMP, version = p.version + 1
|
||||
FROM species s
|
||||
JOIN species_categories c ON c.id = s.category_id
|
||||
WHERE p.species_id = s.id
|
||||
AND p.status = 'alive'
|
||||
AND p.acquired_at IS NOT NULL
|
||||
AND c.lifecycle IN ('annual', 'biennial')
|
||||
AND EXTRACT(YEAR FROM $1::date)::int >=
|
||||
EXTRACT(YEAR FROM p.acquired_at)::int
|
||||
+ CASE WHEN (EXTRACT(MONTH FROM p.acquired_at)::int, EXTRACT(DAY FROM p.acquired_at)::int) >= ($2, $3) THEN 1 ELSE 0 END
|
||||
+ CASE c.lifecycle WHEN 'biennial' THEN 1 ELSE 0 END
|
||||
RETURNING p.id`, asOf, removalMonth, removalDay)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := []int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at)
|
||||
SELECT unnest($2::bigint[]), 'alive', 'removed', 'lifecycle_reached', $1`, asOf, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE plant_locations SET removed_at = $1, version = version + 1 WHERE plant_id = ANY($2) AND removed_at IS NULL`, asOf, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET active = false, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE plant_id = ANY($1) AND template_id IS NOT NULL AND completed_at IS NULL AND active = true`, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestRemoveExpiredPlantsClosesRelatedRecordsAndWritesHistory(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("150405.000000000")
|
||||
var userID, gardenID, categoryID, biennialCategoryID, speciesID, biennialSpeciesID, plantID, biennialPlantID, locationID, templateID, taskID, manualTaskID int
|
||||
if err = db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Lifecycle integration', $1, 'hash', true) RETURNING id`, "lifecycle-"+stamp+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Lifecycle integration') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'annual') RETURNING id`, "Annual "+stamp).Scan(&categoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'biennial') RETURNING id`, "Biennial "+stamp).Scan(&biennialCategoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID)
|
||||
_, _ = db.Exec(`DELETE FROM species_categories WHERE id IN ($1, $2)`, categoryID, biennialCategoryID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Sommerblume', $2) RETURNING id`, gardenID, categoryID).Scan(&speciesID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Zweijährige Blume', $2) RETURNING id`, gardenID, biennialCategoryID).Scan(&biennialSpeciesID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, day_from) VALUES ($1, 'Pflegen', 'month_of_year', 3, 10) RETURNING id`, speciesID).Scan(&templateID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Sommerblume', '2026-03-10') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Zweijährige Blume', '2026-03-10') RETURNING id`, gardenID, biennialSpeciesID).Scan(&biennialPlantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO locations (garden_id, name) VALUES ($1, 'Beet') RETURNING id`, gardenID).Scan(&locationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`INSERT INTO plant_locations (plant_id, location_id, planted_at) VALUES ($1, $2, '2026-03-10')`, plantID, locationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, template_id, title, created_by) VALUES ($1, $2, $3, 'Pflegen', $4) RETURNING id`, gardenID, plantID, templateID, userID).Scan(&taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, title, created_by) VALUES ($1, $2, 'Dokumentieren', $3) RETURNING id`, gardenID, plantID, userID).Scan(&manualTaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
count, err := (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("remove expired plants: count=%d err=%v", count, err)
|
||||
}
|
||||
var status string
|
||||
var removedAt time.Time
|
||||
if err = db.QueryRow(`SELECT status, removed_at FROM plants WHERE id = $1`, plantID).Scan(&status, &removedAt); err != nil || status != "removed" || removedAt.Format("2006-01-02") != "2026-12-01" {
|
||||
t.Fatalf("plant status=%q removed_at=%v err=%v", status, removedAt, err)
|
||||
}
|
||||
var assignmentClosed, taskActive bool
|
||||
if err = db.QueryRow(`SELECT removed_at IS NOT NULL FROM plant_locations WHERE plant_id = $1`, plantID).Scan(&assignmentClosed); err != nil || !assignmentClosed {
|
||||
t.Fatalf("assignment closed=%v err=%v", assignmentClosed, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, taskID).Scan(&taskActive); err != nil || taskActive {
|
||||
t.Fatalf("task active=%v err=%v", taskActive, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, manualTaskID).Scan(&taskActive); err != nil || !taskActive {
|
||||
t.Fatalf("manual task active=%v err=%v", taskActive, err)
|
||||
}
|
||||
var historyCount int
|
||||
if err = db.QueryRow(`SELECT count(*) FROM plant_status_history WHERE plant_id = $1 AND reason = 'lifecycle_reached'`, plantID).Scan(&historyCount); err != nil || historyCount != 1 {
|
||||
t.Fatalf("history count=%d err=%v", historyCount, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "alive" {
|
||||
t.Fatalf("biennial plant was removed too early: status=%q err=%v", status, err)
|
||||
}
|
||||
count, err = (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2027, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("remove biennial plant: count=%d err=%v", count, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "removed" {
|
||||
t.Fatalf("biennial plant status=%q err=%v", status, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// CareInstructionModel implements storage.CareInstructionModelInterface for
|
||||
// PostgreSQL and scopes every lookup through the owning garden.
|
||||
type CareInstructionModel struct{ DB *sql.DB }
|
||||
|
||||
const careInstructionColumns = `ci.id, ci.species_id, ci.text, ci.status, ci.created_by, ci.updated_by, ci.created_at, ci.updated_at, ci.version`
|
||||
|
||||
func scanCareInstruction(s scanner) (storage.CareInstruction, error) {
|
||||
var item storage.CareInstruction
|
||||
err := s.Scan(&item.ID, &item.SpeciesID, &item.Text, &item.Status, &item.CreatedBy, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
return item, err
|
||||
}
|
||||
|
||||
// Insert creates a care instruction.
|
||||
func (m CareInstructionModel) Insert(item storage.CareInstruction) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO care_instructions(species_id,text,status,created_by,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING id,created_at,updated_at,version`, item.SpeciesID, item.Text, item.Status, item.CreatedBy, item.UpdatedBy).Scan(&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
return item, recordError(err)
|
||||
}
|
||||
|
||||
// Get returns a care instruction within its garden and species.
|
||||
func (m CareInstructionModel) Get(gardenID, speciesID, id int) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
return scanCareInstruction(m.DB.QueryRowContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.id=$1 AND ci.species_id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, id, speciesID, gardenID))
|
||||
}
|
||||
|
||||
// GetAllForSpecies lists care instructions for a species visible in a garden.
|
||||
func (m CareInstructionModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.species_id=$1 AND (s.garden_id IS NULL OR s.garden_id=$2) ORDER BY ci.id`, speciesID, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []storage.CareInstruction{}
|
||||
for rows.Next() {
|
||||
item, e := scanCareInstruction(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a care instruction using optimistic locking.
|
||||
func (m CareInstructionModel) Update(gardenID int, item storage.CareInstruction) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE care_instructions ci SET text=$1,status=$2,updated_by=$3,updated_at=now(),version=ci.version+1 FROM species s WHERE ci.species_id=s.id AND ci.id=$4 AND ci.version=$5 AND (s.garden_id IS NULL OR s.garden_id=$6) RETURNING ci.updated_at,ci.version`, item.Text, item.Status, item.UpdatedBy, item.ID, item.Version, gardenID).Scan(&item.UpdatedAt, &item.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.CareInstruction{}, storage.ErrEditConflict
|
||||
}
|
||||
return item, recordError(err)
|
||||
}
|
||||
|
||||
// Delete removes a care instruction within its garden and species.
|
||||
func (m CareInstructionModel) Delete(gardenID, speciesID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM care_instructions ci USING species s WHERE ci.species_id=s.id AND ci.species_id=$1 AND ci.id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, speciesID, id, gardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
|
||||
package postgres
|
||||
@@ -0,0 +1,126 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenInviteModel stores garden invitations in PostgreSQL.
|
||||
// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL.
|
||||
type GardenInviteModel struct{ DB *sql.DB }
|
||||
|
||||
// Upsert creates or replaces a pending invitation for a garden and email.
|
||||
func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
|
||||
invite.Token = rand.Text()
|
||||
hash := sha256.Sum256([]byte(invite.Token))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at)
|
||||
SELECT $1,$2,$3,$4,$5,$6
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET
|
||||
role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by,
|
||||
expires_at=EXCLUDED.expires_at, created_at=now()
|
||||
RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
func scanInvite(row scanner) (storage.GardenInvite, error) {
|
||||
var invite storage.GardenInvite
|
||||
err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
// GetByToken returns an unexpired pending invitation by its plaintext token.
|
||||
func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenInvite{}, recordError(err)
|
||||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists pending invitations for a garden.
|
||||
func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.GardenInvite{}
|
||||
for rows.Next() {
|
||||
invite, err := scanInvite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, invite)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Delete revokes an invitation within its garden.
|
||||
func (m GardenInviteModel) Delete(gardenID, inviteID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept consumes an invitation and creates or updates membership in one
|
||||
// transaction so a token cannot be accepted twice concurrently.
|
||||
func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role}
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = GardenMemberModel(m).loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenMemberModel stores garden memberships in PostgreSQL.
|
||||
// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
|
||||
type GardenMemberModel struct{ DB *sql.DB }
|
||||
|
||||
func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
|
||||
UNION ALL
|
||||
SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
|
||||
ORDER BY granted DESC`, member.Role, member.GardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
base := []string{}
|
||||
overrides := []storage.GardenRolePermissionOverride{}
|
||||
for rows.Next() {
|
||||
var permission storage.GardenPermission
|
||||
var granted bool
|
||||
if err := rows.Scan(&permission, &granted); err != nil {
|
||||
return err
|
||||
}
|
||||
if granted {
|
||||
base = append(base, string(permission))
|
||||
} else {
|
||||
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
member.Permissions = storage.ResolveGardenPermissions(base, overrides)
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGardenMember(s scanner) (storage.GardenMember, error) {
|
||||
var member storage.GardenMember
|
||||
err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
|
||||
return member, err
|
||||
}
|
||||
|
||||
// Insert adds a user to a garden.
|
||||
func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
SELECT $1, $2, $3
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
|
||||
).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Get returns a user's membership and effective permissions in a garden.
|
||||
func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
|
||||
SELECT garden_id, user_id, role, joined_at
|
||||
FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists garden members with effective permissions.
|
||||
func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
|
||||
FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
|
||||
ORDER BY joined_at, user_id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []storage.GardenMember{}
|
||||
for rows.Next() {
|
||||
var member storage.GardenMember
|
||||
err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, member)
|
||||
if err = m.loadPermissions(&members[len(members)-1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a member's garden role.
|
||||
func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
var current storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return storage.GardenMember{}, countErr
|
||||
} else if count == 0 {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Delete removes a non-owner membership from a garden.
|
||||
func (m GardenMemberModel) Delete(gardenID, userID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var role storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if role == storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
|
||||
return err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransferOwnership swaps owner and member roles atomically while preserving
|
||||
// the invariant that a garden always has one owner.
|
||||
func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
|
||||
if fromUserID == toUserID {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var fromRole, toRole storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if fromRole != storage.GardenRoleOwner {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenModel stores gardens in PostgreSQL.
|
||||
// GardenModel implements storage.GardenModelInterface for PostgreSQL.
|
||||
type GardenModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert creates a garden and its owner membership atomically.
|
||||
func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
INSERT INTO gardens (name, description, image_data, image_id)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, created_at, updated_at, version`
|
||||
err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan(
|
||||
&garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func scanGarden(s scanner) (storage.Garden, error) {
|
||||
var garden storage.Garden
|
||||
err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version)
|
||||
return garden, err
|
||||
}
|
||||
|
||||
// Get returns a garden by ID.
|
||||
func (m GardenModel) Get(id int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
garden, err := scanGarden(m.DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version
|
||||
FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id))
|
||||
if err != nil {
|
||||
return storage.Garden{}, recordError(err)
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// GetAllForUser lists gardens visible to a user with resolved permissions.
|
||||
func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role
|
||||
FROM gardens g
|
||||
INNER JOIN garden_members gm ON gm.garden_id = g.id
|
||||
LEFT JOIN images i ON i.id=g.image_id
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY g.name, g.id`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
gardens := []storage.Garden{}
|
||||
for rows.Next() {
|
||||
var garden storage.Garden
|
||||
err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gardens = append(gardens, garden)
|
||||
}
|
||||
return gardens, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a garden using optimistic locking.
|
||||
func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE gardens
|
||||
SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $4 AND version = $5
|
||||
RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version,
|
||||
).Scan(&garden.UpdatedAt, &garden.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Garden{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// Delete removes a garden and its dependent records.
|
||||
func (m GardenModel) Delete(id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const queryTimeout = 3 * time.Second
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func contextWithTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
func jsonValue(value json.RawMessage) json.RawMessage {
|
||||
if len(value) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func deleteByID(db *sql.DB, query string, args ...any) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordError(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
var pqError *pq.Error
|
||||
if errors.As(err, &pqError) && pqError.Code == "23505" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if errors.As(err, &pqError) && pqError.Code == "23503" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableUserID(id int) any {
|
||||
if id < 1 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image
|
||||
// retrieval is always constrained by the owning garden.
|
||||
type ImageModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert stores an image in its garden's library.
|
||||
func (m ImageModel) Insert(image storage.Image) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
sum := sha256.Sum256(image.Data)
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName,
|
||||
image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
image.Data = nil
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// Get returns an image within its garden.
|
||||
func (m ImageModel) Get(gardenID, id int) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var image storage.Image
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan(
|
||||
&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists image metadata matching filter.
|
||||
func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at
|
||||
FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.Image{}
|
||||
for rows.Next() {
|
||||
var image storage.Image
|
||||
if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, image)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CountForGarden returns the number of images in a garden library.
|
||||
func (m ImageModel) CountForGarden(gardenID int) (int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var count int
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RecordAssignment appends an entity image-change history record.
|
||||
func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error {
|
||||
if sameImage(c.PreviousImageID, c.ImageID) {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
_, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy))
|
||||
return err
|
||||
}
|
||||
|
||||
func sameImage(a, b *int) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// JournalModel implements storage.JournalModelInterface for PostgreSQL and
|
||||
// enforces the garden boundary on entries and attachments.
|
||||
type JournalModel struct{ DB *sql.DB }
|
||||
|
||||
const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version`
|
||||
|
||||
func scanJournalEntry(s scanner) (storage.JournalEntry, error) {
|
||||
var entry storage.JournalEntry
|
||||
err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// Insert creates a journal entry and its tags atomically.
|
||||
func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entry.EntryType == "" {
|
||||
entry.EntryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Get returns an entry with tags and attachment metadata within its garden.
|
||||
func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
entry.Attachments, err = m.attachments(ctx, entry.ID)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// GetAllForGarden lists entries of an optional type in a garden.
|
||||
func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entryType == "" {
|
||||
entryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []storage.JournalEntry{}
|
||||
for rows.Next() {
|
||||
entry, scanErr := scanJournalEntry(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].Attachments, err = m.attachments(ctx, entries[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Update changes an entry and its tags atomically using optimistic locking.
|
||||
func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.JournalEntry{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Delete removes an entry within its garden.
|
||||
func (m JournalModel) Delete(gardenID, id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id)
|
||||
}
|
||||
|
||||
// InsertAttachment adds inline media or a library-image link to an entry.
|
||||
func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var entryType storage.JournalEntryType
|
||||
if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
if attachment.ImageID != nil {
|
||||
image, err := ImageModel(m).Get(gardenID, *attachment.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.MediaType, attachment.Size = image.MediaType, image.Size
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = image.FileName
|
||||
}
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = "Bild"
|
||||
}
|
||||
} else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" {
|
||||
image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)})
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.ImageID = &image.ID
|
||||
}
|
||||
data := attachment.Data
|
||||
if attachment.ImageID != nil {
|
||||
data = []byte{}
|
||||
}
|
||||
size := int64(len(attachment.Data))
|
||||
if attachment.ImageID != nil {
|
||||
size = attachment.Size
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
attachment.EntryID, attachment.Size = entryID, size
|
||||
attachment.Data = nil
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// GetAttachment returns attachment metadata and data within its garden and entry.
|
||||
func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var a storage.JournalAttachment
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAttachment removes an attachment within its garden and entry.
|
||||
func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID)
|
||||
}
|
||||
|
||||
func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) {
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.JournalAttachment{}
|
||||
for rows.Next() {
|
||||
var a storage.JournalAttachment
|
||||
if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var userID, gardenID, foreignGardenID int
|
||||
if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
|
||||
model := JournalModel{DB: db}
|
||||
entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert entry: %v", err)
|
||||
}
|
||||
foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign entry: %v", err)
|
||||
}
|
||||
if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign lookup: %v", err)
|
||||
}
|
||||
pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard entry: %v", err)
|
||||
}
|
||||
|
||||
tags := TagModel{DB: db}
|
||||
storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"})
|
||||
if err != nil || len(storedTags) != 2 {
|
||||
t.Fatalf("set tags: %#v, %v", storedTags, err)
|
||||
}
|
||||
attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert attachment: %v", err)
|
||||
}
|
||||
pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard attachment: %v", err)
|
||||
}
|
||||
if pinboardAttachment.ImageID == nil {
|
||||
t.Fatal("pinboard image was not added to the shared image library")
|
||||
}
|
||||
pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID)
|
||||
if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) {
|
||||
t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err)
|
||||
}
|
||||
loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID)
|
||||
if err != nil || string(loaded.Data) != "jpeg" {
|
||||
t.Fatalf("load attachment: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign attachment lookup: %v", err)
|
||||
}
|
||||
|
||||
entry.Title = "Große Ernte"
|
||||
updated, err := model.Update(gardenID, entry)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("update entry: %#v, %v", updated, err)
|
||||
}
|
||||
listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal)
|
||||
if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" {
|
||||
t.Fatalf("list entries: %#v, %v", listed, err)
|
||||
}
|
||||
pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard)
|
||||
if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 {
|
||||
t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err)
|
||||
}
|
||||
if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil {
|
||||
t.Fatalf("delete attachment: %v", err)
|
||||
}
|
||||
if err = model.Delete(gardenID, entry.ID); err != nil {
|
||||
t.Fatalf("delete entry: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// LocationModel stores garden locations in PostgreSQL.
|
||||
// LocationModel implements storage.LocationModelInterface for PostgreSQL and
|
||||
// scopes hierarchical locations to their garden.
|
||||
type LocationModel struct{ DB *sql.DB }
|
||||
|
||||
const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm,
|
||||
l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version,
|
||||
COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)`
|
||||
|
||||
func scanLocation(s scanner) (storage.Location, error) {
|
||||
var location storage.Location
|
||||
err := s.Scan(
|
||||
&location.ID, &location.GardenID, &location.ParentID, &location.Name,
|
||||
&location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction,
|
||||
&location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy,
|
||||
)
|
||||
return location, err
|
||||
}
|
||||
|
||||
// Insert creates a location in its garden.
|
||||
func (m LocationModel) Insert(location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO locations
|
||||
(garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
location.GardenID, location.ParentID, location.Name, location.Description,
|
||||
location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy),
|
||||
).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Get returns a location within its garden.
|
||||
func (m LocationModel) Get(gardenID, id int) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.Location{}, recordError(err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists locations in a garden.
|
||||
func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+`
|
||||
FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
locations := []storage.Location{}
|
||||
for rows.Next() {
|
||||
location, err := scanLocation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations = append(locations, location)
|
||||
}
|
||||
return locations, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a location using optimistic locking.
|
||||
func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4,
|
||||
area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id = $12 AND id = $13 AND version = $14
|
||||
RETURNING updated_at, version`,
|
||||
location.ParentID, location.Name, location.Description, location.Kind,
|
||||
location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy),
|
||||
gardenID, location.ID, location.Version,
|
||||
).Scan(&location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Location{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Delete removes a location within its garden.
|
||||
func (m LocationModel) Delete(gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var gardenID, foreignGardenID int
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) })
|
||||
|
||||
locations := LocationModel{DB: db}
|
||||
local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local location: %v", err)
|
||||
}
|
||||
foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign location: %v", err)
|
||||
}
|
||||
if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
|
||||
var plantID int
|
||||
if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
t.Fatalf("list plants with planter join: %v", err)
|
||||
}
|
||||
if len(plants) != 1 || plants[0].ID != plantID {
|
||||
t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID)
|
||||
}
|
||||
assignments := PlantLocationModel{DB: db}
|
||||
created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local assignment: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("local assignment has no id")
|
||||
}
|
||||
if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
listed, err := assignments.GetAllForPlant(gardenID, plantID)
|
||||
if err != nil || len(listed) != 1 {
|
||||
t.Fatalf("list assignments: values=%+v err=%v", listed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
DROP TABLE plant_status_history;
|
||||
DROP TABLE application_settings;
|
||||
DROP TABLE journal_attachments;
|
||||
DROP TABLE journal_entry_tags;
|
||||
DROP TABLE journal_entries;
|
||||
DROP TABLE entity_image_history;
|
||||
|
||||
ALTER TABLE locations DROP COLUMN image_id;
|
||||
ALTER TABLE plants DROP COLUMN image_id;
|
||||
ALTER TABLE species DROP COLUMN image_id;
|
||||
ALTER TABLE gardens DROP COLUMN image_id;
|
||||
DROP TABLE images;
|
||||
|
||||
DROP TABLE species_tags;
|
||||
DROP TABLE plant_tags;
|
||||
DROP TABLE task_tags;
|
||||
DROP TABLE tags;
|
||||
DROP TABLE task_priorities;
|
||||
DROP TABLE task_template_opt_outs;
|
||||
DROP TABLE tasks;
|
||||
DROP TABLE plant_locations;
|
||||
DROP TABLE plants;
|
||||
DROP TABLE locations;
|
||||
DROP TABLE species_task_templates;
|
||||
DROP TABLE care_instructions;
|
||||
DROP TABLE species;
|
||||
DROP TABLE species_categories;
|
||||
DROP TABLE garden_role_permission_overrides;
|
||||
DROP TABLE garden_invites;
|
||||
DROP TABLE garden_members;
|
||||
|
||||
ALTER TABLE users DROP CONSTRAINT users_application_role_fkey;
|
||||
DROP TABLE role_permissions;
|
||||
DROP TABLE roles;
|
||||
DROP TABLE gardens;
|
||||
DROP TABLE user_email_changes;
|
||||
DROP TABLE tokens;
|
||||
DROP TABLE sessions;
|
||||
DROP TABLE users;
|
||||
|
||||
DROP TYPE task_template_origin;
|
||||
DROP TYPE task_trigger_type;
|
||||
DROP TYPE care_instruction_status;
|
||||
DROP TYPE plant_status;
|
||||
DROP TYPE plant_lifecycle;
|
||||
DROP TYPE soil_reaction;
|
||||
DROP TYPE soil_condition;
|
||||
DROP TYPE sun_exposure;
|
||||
DROP EXTENSION citext;
|
||||
@@ -0,0 +1,524 @@
|
||||
-- Extensions and domain types
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade');
|
||||
CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy');
|
||||
CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral');
|
||||
CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial');
|
||||
CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested');
|
||||
CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned');
|
||||
CREATE TYPE task_trigger_type AS ENUM (
|
||||
'month_of_year',
|
||||
'relative_to_planting',
|
||||
'relative_to_last_task',
|
||||
'relative_to_sowing',
|
||||
'relative_to_harvest',
|
||||
'relative_to_species_planting'
|
||||
);
|
||||
CREATE TYPE task_template_origin AS ENUM (
|
||||
'manual',
|
||||
'season_sowing',
|
||||
'season_planting',
|
||||
'season_harvest'
|
||||
);
|
||||
|
||||
-- Authentication and users
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
email citext NOT NULL UNIQUE,
|
||||
password_hash bytea NOT NULL,
|
||||
activated boolean NOT NULL,
|
||||
application_role text NOT NULL DEFAULT 'application:user',
|
||||
color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int],
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token text PRIMARY KEY,
|
||||
data bytea NOT NULL,
|
||||
expiry timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_expiry_idx ON sessions (expiry);
|
||||
|
||||
CREATE TABLE tokens (
|
||||
hash bytea PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expiry timestamptz NOT NULL,
|
||||
scope text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_email_changes (
|
||||
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL UNIQUE,
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Gardens, roles, and permissions
|
||||
CREATE TABLE gardens (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
name text PRIMARY KEY,
|
||||
scope text NOT NULL CHECK (scope IN ('application', 'garden')),
|
||||
label text NOT NULL,
|
||||
system boolean NOT NULL DEFAULT false,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT roles_scope_garden_check CHECK (
|
||||
(scope = 'application' AND garden_id IS NULL) OR scope = 'garden'
|
||||
)
|
||||
);
|
||||
CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
PRIMARY KEY (role_name, permission)
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_application_role_fkey
|
||||
FOREIGN KEY (application_role) REFERENCES roles(name);
|
||||
|
||||
CREATE TABLE garden_members (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (garden_id, user_id)
|
||||
);
|
||||
CREATE INDEX garden_members_user_id_idx ON garden_members (user_id);
|
||||
|
||||
CREATE TABLE garden_invites (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX garden_invites_pending_email_unique
|
||||
ON garden_invites (garden_id, email) WHERE accepted_at IS NULL;
|
||||
CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at);
|
||||
|
||||
CREATE TABLE garden_role_permission_overrides (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
granted boolean NOT NULL,
|
||||
PRIMARY KEY (garden_id, role_name, permission)
|
||||
);
|
||||
|
||||
-- Species catalogue and care instructions
|
||||
CREATE TABLE species_categories (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
lifecycle plant_lifecycle,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name));
|
||||
|
||||
CREATE TABLE species (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT,
|
||||
common_name text NOT NULL,
|
||||
cultivar text NOT NULL DEFAULT '',
|
||||
botanical_name text NOT NULL DEFAULT '',
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
winter_protection text,
|
||||
spacing_cm integer,
|
||||
height_cm integer,
|
||||
sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12),
|
||||
sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31),
|
||||
sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12),
|
||||
sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31),
|
||||
planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12),
|
||||
planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31),
|
||||
planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12),
|
||||
planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31),
|
||||
harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12),
|
||||
harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31),
|
||||
harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12),
|
||||
harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31),
|
||||
notes text NOT NULL DEFAULT '',
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_global_unique
|
||||
ON species (common_name, cultivar) WHERE garden_id IS NULL;
|
||||
CREATE UNIQUE INDEX species_garden_unique
|
||||
ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE care_instructions (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
text text NOT NULL,
|
||||
status care_instruction_status NOT NULL DEFAULT 'untested',
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
updated_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX care_instructions_species ON care_instructions(species_id);
|
||||
|
||||
CREATE TABLE species_task_templates (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
trigger_type task_trigger_type NOT NULL,
|
||||
month_from smallint CHECK (month_from BETWEEN 1 AND 12),
|
||||
day_from smallint CHECK (day_from BETWEEN 1 AND 31),
|
||||
month_to smallint CHECK (month_to BETWEEN 1 AND 12),
|
||||
day_to smallint CHECK (day_to BETWEEN 1 AND 31),
|
||||
offset_days_from integer,
|
||||
offset_days_to integer,
|
||||
interval_days smallint,
|
||||
trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0),
|
||||
trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')),
|
||||
duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0),
|
||||
duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')),
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
origin task_template_origin NOT NULL DEFAULT 'manual',
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to)
|
||||
);
|
||||
CREATE INDEX species_task_templates_species_id_idx
|
||||
ON species_task_templates (species_id) WHERE active = true;
|
||||
CREATE UNIQUE INDEX species_task_templates_derived_origin_key
|
||||
ON species_task_templates (species_id, origin) WHERE origin <> 'manual';
|
||||
|
||||
-- Locations, plants, and tasks
|
||||
CREATE TABLE locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
parent_id bigint REFERENCES locations(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
kind text NOT NULL DEFAULT '',
|
||||
area_sqm numeric(10,2),
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX locations_garden_id_idx ON locations (garden_id);
|
||||
CREATE INDEX locations_parent_id_idx ON locations (parent_id);
|
||||
|
||||
CREATE TABLE plants (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
species_id bigint REFERENCES species(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
acquired_at date,
|
||||
status plant_status NOT NULL DEFAULT 'alive',
|
||||
removed_at date,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
planted_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive';
|
||||
|
||||
CREATE TABLE plant_locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||
quantity integer NOT NULL DEFAULT 1,
|
||||
planted_at date,
|
||||
removed_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX plant_location_active
|
||||
ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL;
|
||||
CREATE INDEX plant_locations_location_id_idx
|
||||
ON plant_locations (location_id) WHERE removed_at IS NULL;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
plant_id bigint REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint REFERENCES locations(id) ON DELETE CASCADE,
|
||||
template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
due_at_start timestamptz,
|
||||
due_at_end timestamptz,
|
||||
generated_for date,
|
||||
completed_at timestamptz,
|
||||
completed_by bigint REFERENCES users(id),
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
plant_status_on_completion plant_status,
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end),
|
||||
CONSTRAINT tasks_completion_user_check CHECK (
|
||||
(completed_at IS NULL AND completed_by IS NULL) OR
|
||||
(completed_at IS NOT NULL AND completed_by IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX tasks_garden_id_due_at_end_idx
|
||||
ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_garden_id_due_at_start_idx
|
||||
ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_repeat_from_unique
|
||||
ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_template_slot_unique
|
||||
ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE task_template_opt_outs (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (plant_id, template_id)
|
||||
);
|
||||
|
||||
CREATE TABLE task_priorities (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL UNIQUE,
|
||||
value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Tags
|
||||
CREATE TABLE tags (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (garden_id, name)
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL;
|
||||
|
||||
CREATE TABLE task_tags (
|
||||
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
CREATE TABLE plant_tags (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (plant_id, tag_id)
|
||||
);
|
||||
CREATE TABLE species_tags (
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (species_id, tag_id)
|
||||
);
|
||||
|
||||
-- Image library
|
||||
CREATE TABLE images (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
file_name text NOT NULL DEFAULT '',
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
checksum text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'upload',
|
||||
created_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum);
|
||||
|
||||
ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE entity_image_history (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')),
|
||||
entity_id bigint NOT NULL,
|
||||
previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
changed_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX entity_image_history_entity_idx
|
||||
ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC);
|
||||
|
||||
-- Journal
|
||||
CREATE TABLE journal_entries (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
author_id bigint NOT NULL REFERENCES users(id),
|
||||
entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')),
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX journal_entries_garden_created_idx
|
||||
ON journal_entries(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX journal_entries_garden_type_created_idx
|
||||
ON journal_entries(garden_id, entry_type, created_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE journal_entry_tags (
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (journal_entry_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE journal_attachments (
|
||||
id bigserial PRIMARY KEY,
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
image_id bigint REFERENCES images(id) ON DELETE RESTRICT,
|
||||
file_name text NOT NULL,
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id);
|
||||
|
||||
-- Application settings and lifecycle history
|
||||
CREATE TABLE application_settings (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
lifecycle_status_enabled boolean NOT NULL DEFAULT false,
|
||||
lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12),
|
||||
lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31),
|
||||
timezone text NOT NULL DEFAULT 'Europe/Berlin',
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE plant_status_history (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
from_status plant_status NOT NULL,
|
||||
to_status plant_status NOT NULL,
|
||||
reason text NOT NULL,
|
||||
effective_at date NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX plant_status_history_plant_id_idx
|
||||
ON plant_status_history (plant_id, created_at DESC);
|
||||
|
||||
-- Initial reference data
|
||||
INSERT INTO roles (name, scope, label, system) VALUES
|
||||
('application:user', 'application', 'Nutzer', true),
|
||||
('application:admin', 'application', 'Administrator', true),
|
||||
('owner', 'garden', 'Eigentümer', true),
|
||||
('admin', 'garden', 'Administrator', true),
|
||||
('member', 'garden', 'Mitglied', true),
|
||||
('worker', 'garden', 'Mitarbeiter', true),
|
||||
('viewer', 'garden', 'Leser', true);
|
||||
|
||||
INSERT INTO role_permissions (role_name, permission) VALUES
|
||||
('application:user', 'gardens:create'),
|
||||
('application:admin', 'gardens:create'),
|
||||
('application:admin', 'global_species:write'),
|
||||
('application:admin', 'users:manage'),
|
||||
('application:admin', 'application_settings:write'),
|
||||
('application:admin', 'roles:manage'),
|
||||
('owner', '*'),
|
||||
('admin', 'garden:*'),
|
||||
('member', 'garden:read'),
|
||||
('member', 'content:write'),
|
||||
('member', 'plants:create'),
|
||||
('member', 'plants:read:own'),
|
||||
('member', 'plants:read:other'),
|
||||
('member', 'plants:update:own'),
|
||||
('member', 'plants:delete:own'),
|
||||
('member', 'locations:create'),
|
||||
('member', 'locations:read:own'),
|
||||
('member', 'locations:read:other'),
|
||||
('member', 'locations:update:own'),
|
||||
('member', 'locations:delete:own'),
|
||||
('member', 'tasks:create'),
|
||||
('member', 'tasks:read:own'),
|
||||
('member', 'tasks:read:other'),
|
||||
('member', 'tasks:update:own'),
|
||||
('member', 'tasks:delete:own'),
|
||||
('member', 'tasks:complete:own'),
|
||||
('member', 'tasks:complete:other'),
|
||||
('worker', 'garden:read'),
|
||||
('worker', 'tasks:read:own'),
|
||||
('worker', 'tasks:read:other'),
|
||||
('worker', 'tasks:complete:own'),
|
||||
('worker', 'tasks:complete:other'),
|
||||
('viewer', 'garden:read'),
|
||||
('viewer', 'plants:read:own'),
|
||||
('viewer', 'plants:read:other'),
|
||||
('viewer', 'locations:read:own'),
|
||||
('viewer', 'locations:read:other'),
|
||||
('viewer', 'tasks:read:own'),
|
||||
('viewer', 'tasks:read:other');
|
||||
|
||||
INSERT INTO task_priorities (name, value, sort_order) VALUES
|
||||
('Niedrig', -5, 10),
|
||||
('Normal', 0, 20),
|
||||
('Erhöht', 3, 30),
|
||||
('Hoch', 5, 40);
|
||||
|
||||
INSERT INTO application_settings (singleton) VALUES (true);
|
||||
|
||||
INSERT INTO species_categories (name, sort_order) VALUES
|
||||
('Gehölz', 10),
|
||||
('Gemüse', 20),
|
||||
('Kraut', 30),
|
||||
('Obst', 40),
|
||||
('Staude', 50);
|
||||
@@ -0,0 +1,33 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// New binds all PostgreSQL model implementations to db.
|
||||
func New(db *sql.DB) storage.Models {
|
||||
return storage.Models{
|
||||
ApplicationSettings: ApplicationSettingsModel{DB: db},
|
||||
Roles: RoleModel{DB: db},
|
||||
Gardens: GardenModel{DB: db},
|
||||
GardenMembers: GardenMemberModel{DB: db},
|
||||
GardenInvites: GardenInviteModel{DB: db},
|
||||
Locations: LocationModel{DB: db},
|
||||
Plants: PlantModel{DB: db},
|
||||
PlantLocations: PlantLocationModel{DB: db},
|
||||
Species: SpeciesModel{DB: db},
|
||||
CareInstructions: CareInstructionModel{DB: db},
|
||||
SpeciesCategories: SpeciesCategoryModel{DB: db},
|
||||
TaskPriorities: TaskPriorityModel{DB: db},
|
||||
SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db},
|
||||
Tasks: TaskModel{DB: db},
|
||||
Journal: JournalModel{DB: db},
|
||||
Images: ImageModel{DB: db},
|
||||
Tags: TagModel{DB: db},
|
||||
TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db},
|
||||
Tokens: TokenModel{DB: db},
|
||||
Users: UserModel{DB: db},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user