Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+250
View File
@@ -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)
}
}
+153
View File
@@ -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)
}
+78
View File
@@ -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)
}
}
+166
View File
@@ -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)
}
}
+177
View File
@@ -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
}
+100
View File
@@ -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
}
+121
View File
@@ -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)
}
}
}
+1
View File
@@ -0,0 +1 @@
package api
+157
View File
@@ -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
}
+33
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
// Package api implements the Gardomatic HTTP API, including authentication,
// authorization middleware, request validation, and JSON response handling.
package api
+99
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
+187
View File
@@ -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)
}
}
+275
View File
@@ -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())
}
}
+22
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+175
View File
@@ -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()
})
}
+72
View File
@@ -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)
}
+332
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+243
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+351
View File
@@ -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)
})
}
+147
View File
@@ -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)
}
}
}
+195
View File
@@ -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
}
+257
View File
@@ -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
}
+250
View File
@@ -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)
}
}
+256
View File
@@ -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)
}
+140
View File
@@ -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)))
}
+93
View File
@@ -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)
}
+53
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+97
View File
@@ -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)
}
+210
View File
@@ -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)
}
}
+396
View File
@@ -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)
}
}
+141
View File
@@ -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
}
+83
View File
@@ -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())
}
}
+300
View File
@@ -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())
}
}
+34
View File
@@ -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)
}
+142
View File
@@ -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()
}
+73
View File
@@ -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)
}
}
}
+153
View File
@@ -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
}
+44
View File
@@ -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)
}
+371
View File
@@ -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
}
+133
View File
@@ -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)
}
}
+189
View File
@@ -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)
}
}
+210
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package auth provides password hashing and secure token primitives used by
// Gardomatic authentication flows.
package auth
+71
View File
@@ -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")
}
+53
View File
@@ -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")
}
+46
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package mailer renders and delivers Gardomatic transactional email through
// SMTP or an append-only development file.
package mailer
+163
View File
@@ -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
}
+64
View File
@@ -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}}
+25
View File
@@ -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")
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package validate contains reusable validation helpers and an error collector
// for API and form inputs.
package validate
+44
View File
@@ -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
}
+67
View File
@@ -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)
}
}
+36
View File
@@ -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")
}
}
+37
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
// Package storage defines Gardomatic persistence models and the interfaces
// implemented by database-specific adapters.
package storage
+10
View File
@@ -0,0 +1,10 @@
package storage
import (
"errors"
)
var (
// ErrDuplicateEmail indicates that a user email is already registered.
ErrDuplicateEmail = errors.New("models: duplicate email")
)
+76
View File
@@ -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,
}
}
+25
View File
@@ -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"`
}
+202
View File
@@ -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
}
+38
View File
@@ -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")
}
+44
View File
@@ -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
}
+77
View File
@@ -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")
}
+22
View File
@@ -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)
}
}
+59
View File
@@ -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"`
}
+38
View File
@@ -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
}
+42
View File
@@ -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")
}
}
+54
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
package postgres
+126
View File
@@ -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
}
+222
View File
@@ -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(&current); 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()
}
+116
View File
@@ -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)
}
+87
View File
@@ -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
}
+90
View File
@@ -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
}
+175
View File
@@ -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)
}
}
+118
View File
@@ -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);
+33
View File
@@ -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