This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"gardomatic.kleiax.de/internal/api"
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
"gardomatic.kleiax.de/internal/platform/environment"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func configFromEnvironment() (api.Config, error) {
|
||||
@@ -55,6 +57,9 @@ func configFromEnvironment() (api.Config, error) {
|
||||
Port: integer("GARDOMATIC_API_PORT", 4000),
|
||||
Env: environment.String("GARDOMATIC_ENV", "development"),
|
||||
WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||
DemoAccountEmail: strings.ToLower(strings.TrimSpace(
|
||||
environment.String("GARDOMATIC_DEMO_ACCOUNT_EMAIL", ""),
|
||||
)),
|
||||
DB: api.DatabaseConfig{
|
||||
Dsn: required("GARDOMATIC_DB_DSN"), MaxOpenConns: integer("GARDOMATIC_DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: integer("GARDOMATIC_DB_MAX_IDLE_CONNS", 25), MaxIdleTime: duration("GARDOMATIC_DB_MAX_IDLE_TIME", 15*time.Minute),
|
||||
@@ -92,6 +97,12 @@ func configFromEnvironment() (api.Config, error) {
|
||||
if cfg.Env == "production" && !cfg.Session.CookieSecure {
|
||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
||||
}
|
||||
if cfg.DemoAccountEmail != "" {
|
||||
v := validate.New()
|
||||
if storage.ValidateEmail(v, cfg.DemoAccountEmail); !v.Valid() {
|
||||
errs = append(errs, errors.New("GARDOMATIC_DEMO_ACCOUNT_EMAIL must be a valid email address"))
|
||||
}
|
||||
}
|
||||
if _, err := mailer.New(cfg.Mail); err != nil {
|
||||
errs = append(errs, fmt.Errorf("mail configuration: %w", err))
|
||||
}
|
||||
|
||||
+10
-1
@@ -7,15 +7,24 @@ func TestConfigFromEnvironment(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_API_HOST", "127.0.0.1")
|
||||
t.Setenv("GARDOMATIC_API_PORT", "4100")
|
||||
t.Setenv("GARDOMATIC_CORS_TRUSTED_ORIGINS", "https://example.com, https://app.example.com")
|
||||
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", " Demo@Example.com ")
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 {
|
||||
if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 || cfg.DemoAccountEmail != "demo@example.com" {
|
||||
t.Fatalf("unexpected config: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsInvalidDemoAccountEmail(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_DB_DSN", "postgres://example")
|
||||
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", "not-an-email")
|
||||
if _, err := configFromEnvironment(); err == nil {
|
||||
t.Fatal("expected invalid demo account email error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRequiresDSN(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_DB_DSN", "")
|
||||
if _, err := configFromEnvironment(); err == nil {
|
||||
|
||||
@@ -92,11 +92,60 @@ func (app *application) run(ctx context.Context, args []string) error {
|
||||
return app.runGardens(ctx, cfg, store, remaining[1:])
|
||||
case "db":
|
||||
return app.runDB(ctx, cfg, store, remaining[1:])
|
||||
case "demo":
|
||||
return app.runDemo(ctx, cfg, store, remaining[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) runDemo(ctx context.Context, cfg config, store adminStore, args []string) error {
|
||||
if len(args) == 0 || args[0] != "reset" {
|
||||
return errors.New("usage: gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL]")
|
||||
}
|
||||
if !cfg.demoResetEnabled {
|
||||
return errors.New("demo reset is disabled; set GARDOMATIC_DEMO_RESET_ENABLED=true only for a disposable demo database")
|
||||
}
|
||||
if !cfg.yes {
|
||||
return errors.New("demo reset deletes all user and garden data; pass the global --yes flag")
|
||||
}
|
||||
|
||||
fs := newFlagSet("demo reset", app.stderr)
|
||||
var name, email string
|
||||
var passwordStdin bool
|
||||
fs.StringVar(&name, "name", "Demo-Besucher", "demo account display name")
|
||||
fs.StringVar(&email, "email", "demo@example.com", "demo account email address")
|
||||
fs.BoolVar(&passwordStdin, "password-stdin", false, "read the demo account password from standard input (required)")
|
||||
if err := parseFlags(fs, args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
name, email = strings.TrimSpace(name), strings.TrimSpace(email)
|
||||
if !passwordStdin {
|
||||
return errors.New("--password-stdin is required so the demo password is not exposed in the process list")
|
||||
}
|
||||
if err := validateIdentity(name, email); err != nil {
|
||||
return err
|
||||
}
|
||||
password, _, err := app.obtainPassword(false, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
passwordHash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := store.ResetDemo(ctx, demoResetInput{Name: name, Email: email, PasswordHash: passwordHash})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset demo data: %w", err)
|
||||
}
|
||||
if cfg.json {
|
||||
return writeJSON(app.stdout, result)
|
||||
}
|
||||
_, err = fmt.Fprintf(app.stdout, "Demo reset complete.\nLogin: %s\nGarden: %s\nImages: %d\nLocations: %d\nSpecies: %d\nPlants: %d\nTasks: %d\nJournal entries: %d\nAttachments: %d\n",
|
||||
result.Email, result.Garden, result.Images, result.Locations, result.Species, result.Plants, result.Tasks, result.Journal, result.Attachments)
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role")
|
||||
@@ -554,6 +603,7 @@ Usage:
|
||||
gardomatic [global options] users set-role --email EMAIL --role ROLE
|
||||
gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE]
|
||||
gardomatic [global options] db ping
|
||||
gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL]
|
||||
gardomatic [global options] version
|
||||
|
||||
Global options:`)
|
||||
|
||||
@@ -22,6 +22,8 @@ type fakeAdminStore struct {
|
||||
setRoleCalls int
|
||||
addMemberCalls int
|
||||
lastRole string
|
||||
demoResetCalls int
|
||||
demoResetInput demoResetInput
|
||||
}
|
||||
|
||||
func (s *fakeAdminStore) Ping(context.Context) error { return nil }
|
||||
@@ -71,6 +73,11 @@ func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email,
|
||||
s.lastRole = role
|
||||
return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
|
||||
}
|
||||
func (s *fakeAdminStore) ResetDemo(_ context.Context, input demoResetInput) (demoResetResult, error) {
|
||||
s.demoResetCalls++
|
||||
s.demoResetInput = input
|
||||
return demoResetResult{Email: input.Email, Garden: "Sonnengarten", Images: 3, Locations: 8, Species: 12, Plants: 16, Tasks: 16, Journal: 6, Attachments: 1}, nil
|
||||
}
|
||||
|
||||
func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -124,6 +131,59 @@ func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoResetRequiresExplicitEnablement(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "correct horse battery staple\n", store)
|
||||
|
||||
err := app.run(context.Background(), []string{"--yes", "demo", "reset", "--password-stdin"})
|
||||
if err == nil || !strings.Contains(err.Error(), "GARDOMATIC_DEMO_RESET_ENABLED") {
|
||||
t.Fatalf("run() error = %v, want disabled demo reset error", err)
|
||||
}
|
||||
if store.demoResetCalls != 0 {
|
||||
t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoResetRequiresYes(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "correct horse battery staple\n", store)
|
||||
t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true")
|
||||
|
||||
err := app.run(context.Background(), []string{"demo", "reset", "--password-stdin"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("run() error = %v, want --yes error", err)
|
||||
}
|
||||
if store.demoResetCalls != 0 {
|
||||
t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoResetHashesPasswordAndWritesSummary(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, stdout, _ := newTestApplication(t, "correct horse battery staple\n", store)
|
||||
t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true")
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"--yes", "demo", "reset", "--password-stdin",
|
||||
"--name", " Schaugarten ", "--email", "besuch@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run() returned an error: %v", err)
|
||||
}
|
||||
if store.demoResetCalls != 1 {
|
||||
t.Fatalf("ResetDemo calls = %d, want 1", store.demoResetCalls)
|
||||
}
|
||||
if store.demoResetInput.Name != "Schaugarten" || store.demoResetInput.Email != "besuch@example.com" {
|
||||
t.Errorf("ResetDemo input = %+v", store.demoResetInput)
|
||||
}
|
||||
if err = bcrypt.CompareHashAndPassword(store.demoResetInput.PasswordHash, []byte("correct horse battery staple")); err != nil {
|
||||
t.Errorf("stored password hash does not match input: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Demo reset complete") || !strings.Contains(stdout.String(), "Plants: 16") {
|
||||
t.Errorf("output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "", store)
|
||||
|
||||
+27
-9
@@ -10,12 +10,13 @@ import (
|
||||
)
|
||||
|
||||
type config struct {
|
||||
dsn string
|
||||
environment string
|
||||
webBaseURL string
|
||||
json bool
|
||||
yes bool
|
||||
mail mailer.Config
|
||||
dsn string
|
||||
environment string
|
||||
webBaseURL string
|
||||
demoResetEnabled bool
|
||||
json bool
|
||||
yes bool
|
||||
mail mailer.Config
|
||||
}
|
||||
|
||||
func configFromEnvironment() (config, error) {
|
||||
@@ -23,11 +24,16 @@ func configFromEnvironment() (config, error) {
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
demoResetEnabled, err := envBool("GARDOMATIC_DEMO_RESET_ENABLED", false)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
|
||||
return config{
|
||||
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
|
||||
environment: envString("GARDOMATIC_ENV", "development"),
|
||||
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
|
||||
environment: envString("GARDOMATIC_ENV", "development"),
|
||||
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||
demoResetEnabled: demoResetEnabled,
|
||||
mail: mailer.Config{
|
||||
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
|
||||
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
|
||||
@@ -40,6 +46,18 @@ func configFromEnvironment() (config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) (bool, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s must be a boolean: %w", name, err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func envString(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 347 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
@@ -0,0 +1,616 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const demoGardenName = "Sonnengarten"
|
||||
|
||||
//go:embed demo_assets/sonnengarten.jpg
|
||||
var demoGardenImage []byte
|
||||
|
||||
//go:embed demo_assets/tomaten-gewaechshaus.jpg
|
||||
var demoTomatoImage []byte
|
||||
|
||||
//go:embed demo_assets/apfelbaum-obstwiese.jpg
|
||||
var demoAppleImage []byte
|
||||
|
||||
// ResetDemo replaces all user-created data with a coherent demo data set. The
|
||||
// caller is responsible for guarding this destructive operation.
|
||||
func (s *postgresStore) ResetDemo(ctx context.Context, input demoResetInput) (demoResetResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Serialize resets and keep the old demo usable until the complete new data
|
||||
// set can be committed.
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1279348146)`); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("lock demo reset: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM sessions;
|
||||
DELETE FROM journal_attachments;
|
||||
DELETE FROM gardens;
|
||||
DELETE FROM species;
|
||||
DELETE FROM tags;
|
||||
DELETE FROM users;
|
||||
DELETE FROM roles WHERE system = false;
|
||||
DELETE FROM species_categories;
|
||||
UPDATE application_settings
|
||||
SET lifecycle_status_enabled = true,
|
||||
lifecycle_removal_month = 12,
|
||||
lifecycle_removal_day = 1,
|
||||
timezone = 'Europe/Berlin',
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
version = version + 1;
|
||||
INSERT INTO species_categories (name, sort_order, lifecycle) VALUES
|
||||
('Gehölz', 10, 'perennial'),
|
||||
('Gemüse', 20, 'annual'),
|
||||
('Kraut', 30, 'annual'),
|
||||
('Obst', 40, 'perennial'),
|
||||
('Staude', 50, 'perennial'),
|
||||
('Blume', 60, 'annual')`); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("clear demo data: %w", err)
|
||||
}
|
||||
|
||||
var demoUserID, memberID, workerID, gardenID int64
|
||||
if err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role, color)
|
||||
VALUES ($1, $2, $3, true, 'application:user', '#1b9e77') RETURNING id`,
|
||||
input.Name, input.Email, input.PasswordHash).Scan(&demoUserID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo account: %w", err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role, color)
|
||||
VALUES ('Mara Beispiel', 'mara@example.com', $1, true, 'application:user', '#7570b3') RETURNING id`,
|
||||
input.PasswordHash).Scan(&memberID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create example member: %w", err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role, color)
|
||||
VALUES ('Timo Gartenfreund', 'timo@example.com', $1, true, 'application:user', '#d95f02') RETURNING id`,
|
||||
input.PasswordHash).Scan(&workerID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create example worker: %w", err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO gardens (name, description)
|
||||
VALUES ($1, 'Ein lebendiger Gemeinschaftsgarten mit Gemüsebeeten, Gewächshaus und kleiner Obstwiese.')
|
||||
RETURNING id`, demoGardenName).Scan(&gardenID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo garden: %w", err)
|
||||
}
|
||||
customRole := fmt.Sprintf("erntehilfe-%d", gardenID)
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO roles (name, scope, label, garden_id) VALUES ($1, 'garden', 'Erntehilfe', $2)`, customRole, gardenID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo garden role: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO role_permissions (role_name, permission) VALUES
|
||||
($1, 'garden:read'), ($1, 'tasks:read:own'), ($1, 'tasks:read:other'),
|
||||
($1, 'tasks:complete:own'), ($1, 'tasks:complete:other')`, customRole); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo garden role permissions: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role) VALUES
|
||||
($1, $2, 'owner'),
|
||||
($1, $3, 'member'),
|
||||
($1, $4, $5)`, gardenID, demoUserID, memberID, workerID, customRole); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo memberships: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_role_permission_overrides (garden_id, role_name, permission, granted) VALUES
|
||||
($1, 'member', 'plants:delete:own', false),
|
||||
($1, 'viewer', 'tasks:complete:other', true)`, gardenID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo permission overrides: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_invites (garden_id, email, role, token_hash, invited_by, expires_at)
|
||||
VALUES ($1, 'lea-beispiel@example.com', 'viewer', decode('b462f5f8befe77ecf5c611fd7e2f72138fefd4fa5b41f2f591cc8331187e94f7', 'hex'), $2, CURRENT_TIMESTAMP + interval '5 days')`, gardenID, demoUserID); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("create demo invitation: %w", err)
|
||||
}
|
||||
|
||||
imageIDs, err := seedDemoImages(ctx, tx, gardenID, demoUserID)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE gardens SET image_id = $2 WHERE id = $1`, gardenID, imageIDs["garden"]); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("assign demo garden image: %w", err)
|
||||
}
|
||||
|
||||
locationIDs, err := seedDemoLocations(ctx, tx, gardenID, demoUserID, imageIDs)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
speciesIDs, templateIDs, err := seedDemoSpecies(ctx, tx, gardenID, demoUserID, imageIDs)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
plantIDs, err := seedDemoPlants(ctx, tx, gardenID, demoUserID, memberID, locationIDs, speciesIDs, templateIDs, imageIDs)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
taskCount, err := seedDemoTasks(ctx, tx, gardenID, demoUserID, memberID, locationIDs, plantIDs, templateIDs)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
journalCount, attachmentCount, err := seedDemoJournal(ctx, tx, gardenID, demoUserID, memberID, imageIDs)
|
||||
if err != nil {
|
||||
return demoResetResult{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return demoResetResult{}, fmt.Errorf("commit demo reset: %w", err)
|
||||
}
|
||||
return demoResetResult{
|
||||
Email: input.Email,
|
||||
Garden: demoGardenName,
|
||||
Images: len(imageIDs),
|
||||
Locations: len(locationIDs),
|
||||
Species: len(speciesIDs),
|
||||
Plants: len(plantIDs),
|
||||
Tasks: taskCount,
|
||||
Journal: journalCount,
|
||||
Attachments: attachmentCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func seedDemoImages(ctx context.Context, tx *sql.Tx, gardenID, userID int64) (map[string]int64, error) {
|
||||
images := []struct {
|
||||
key, fileName, source string
|
||||
data []byte
|
||||
}{
|
||||
{"garden", "sonnengarten-uebersicht.jpg", "demo", demoGardenImage},
|
||||
{"tomato", "tomaten-im-gewaechshaus.jpg", "demo", demoTomatoImage},
|
||||
{"apple", "apfelbaum-auf-der-obstwiese.jpg", "demo", demoAppleImage},
|
||||
}
|
||||
ids := make(map[string]int64, len(images))
|
||||
for _, image := range images {
|
||||
sum := sha256.Sum256(image.data)
|
||||
var id int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO images (garden_id, file_name, media_type, data, size, checksum, source, created_by)
|
||||
VALUES ($1, $2, 'image/jpeg', $3, $4, $5, $6, $7) RETURNING id`,
|
||||
gardenID, image.fileName, image.data, len(image.data), hex.EncodeToString(sum[:]), image.source, userID).Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("create demo image %q: %w", image.fileName, err)
|
||||
}
|
||||
ids[image.key] = id
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func seedDemoLocations(ctx context.Context, tx *sql.Tx, gardenID, userID int64, images map[string]int64) (map[string]int64, error) {
|
||||
ids := make(map[string]int64)
|
||||
rows := []struct {
|
||||
key, name, description, kind, parent string
|
||||
area float64
|
||||
sun, soil, reaction, attributes, imageKey string
|
||||
}{
|
||||
{"vegetable", "Gemüsegarten", "Der sonnige Bereich für Frucht-, Wurzel- und Blattgemüse. Eine Tropfleitung versorgt die Hauptreihen.", "Bereich", "", 42, "sunny", "moist", "neutral", `{"bewässerung":"Tropfschlauch","mulch":"Rasenschnitt","bodenprobe":"2026-03-18"}`, ""},
|
||||
{"raised", "Hochbeet Süd", "Kompostreiche Erde, morgens zuerst kontrollieren. Die Beetkante wurde im Frühjahr erneuert.", "Hochbeet", "vegetable", 6.5, "sunny", "moist", "neutral", `{"baujahr":2024,"material":"Lärche","füllung":"Kompost und Gartenerde"}`, ""},
|
||||
{"north_bed", "Beet Nord", "Halbschattiges Beet für Salate, Mangold und empfindliche Jungpflanzen.", "Beet", "vegetable", 9.75, "partial_shade", "moist", "neutral", `{"reihen":4,"fruchtfolge":"Blattgemüse","netz_vorhanden":true}`, ""},
|
||||
{"greenhouse", "Gewächshaus", "Geschützt für Tomaten und wärmeliebende Kulturen. Automatische Fensterheber lüften ab 24 °C.", "Gewächshaus", "", 12, "sunny", "moist", "alkaline", `{"temperatur_sensor":"GH-01","bewässerung":"Tropfer","fensterheber":true}`, "tomato"},
|
||||
{"herbs", "Kräuterecke", "Trockener Standort an der Natursteinmauer mit durchlässigem, kalkhaltigem Substrat.", "Beet", "", 5, "sunny", "dry", "alkaline", `{"substrat":"sandig-kiesig","trockenmauer":true}`, ""},
|
||||
{"orchard", "Obstwiese", "Junge Obstgehölze mit insektenfreundlichem Unterwuchs und gestaffelter Mahd.", "Wiese", "", 85, "partial_shade", "moist", "neutral", `{"mahd":"zweimal jährlich","nistkästen":2,"bewässerung":"Gießringe"}`, "apple"},
|
||||
{"compost", "Kompostplatz", "Drei Kammern für Frischmaterial, Rotte und reifen Kompost.", "Arbeitsbereich", "", 7.2, "partial_shade", "moist", "neutral", `{"kammern":3,"letztes_umsetzen":"2026-05-02","thermometer":true}`, ""},
|
||||
{"pond", "Miniteich", "Flacher Naturteich mit Ausstiegshilfen für Insekten und Kleintiere.", "Wasserstelle", "", 3.4, "sunny", "boggy", "acidic", `{"tiefe_cm":55,"regenwasser":true,"fischfrei":true}`, ""},
|
||||
}
|
||||
for _, row := range rows {
|
||||
var parentID any
|
||||
if row.parent != "" {
|
||||
parentID = ids[row.parent]
|
||||
}
|
||||
var imageID any
|
||||
if row.imageKey != "" {
|
||||
imageID = images[row.imageKey]
|
||||
}
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO locations (garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::sun_exposure, $8::soil_condition, $9::soil_reaction, $10::jsonb, $11, $12, $12)
|
||||
RETURNING id`, gardenID, parentID, row.name, row.description, row.kind, row.area, row.sun, row.soil, row.reaction, row.attributes, imageID, userID).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create demo location %q: %w", row.name, err)
|
||||
}
|
||||
ids[row.key] = id
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
|
||||
($1, 'garden', $1, $2, $4),
|
||||
($1, 'location', $5, $3, $4),
|
||||
($1, 'location', $6, $7, $4)`, gardenID, images["garden"], images["tomato"], userID, ids["greenhouse"], ids["orchard"], images["apple"]); err != nil {
|
||||
return nil, fmt.Errorf("record demo location images: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func seedDemoSpecies(ctx context.Context, tx *sql.Tx, gardenID, userID int64, images map[string]int64) (map[string]int64, map[string]int64, error) {
|
||||
ids := make(map[string]int64)
|
||||
type speciesSeed struct {
|
||||
key, category, commonName, cultivar, botanicalName, notes string
|
||||
spacing, height int
|
||||
sowFrom, sowTo, plantFrom, plantTo, harvestFrom, harvestTo int
|
||||
}
|
||||
rows := []speciesSeed{
|
||||
{"tomato", "Gemüse", "Tomate", "San Marzano", "Solanum lycopersicum", "Robuste Flaschentomate für Sauce und Salat.", 60, 180, 2, 4, 5, 6, 7, 10},
|
||||
{"zucchini", "Gemüse", "Zucchini", "Cocozelle von Tripolis", "Cucurbita pepo", "Regelmäßig jung ernten, dann trägt die Pflanze lange.", 100, 70, 4, 5, 5, 6, 6, 10},
|
||||
{"basil", "Kraut", "Basilikum", "Genoveser", "Ocimum basilicum", "Blüten ausknipsen und nur von oben ernten.", 25, 40, 3, 6, 5, 7, 6, 9},
|
||||
{"strawberry", "Obst", "Erdbeere", "Mieze Schindler", "Fragaria × ananassa", "Aromatische Sorte; eine Befruchtersorte steht in der Nachbarparzelle.", 30, 25, 0, 0, 3, 8, 5, 7},
|
||||
{"lavender", "Staude", "Lavendel", "Hidcote Blue", "Lavandula angustifolia", "Nach der Blüte leicht zurückschneiden.", 40, 55, 0, 0, 3, 6, 7, 8},
|
||||
{"apple", "Gehölz", "Apfel", "Topaz", "Malus domestica", "Junger Halbstamm, im Sommer auf ausreichende Wasserversorgung achten.", 400, 350, 0, 0, 10, 3, 9, 10},
|
||||
{"lettuce", "Gemüse", "Romanasalat", "Forellenschluss", "Lactuca sativa var. longifolia", "Historischer, rot gesprenkelter Romanasalat für den Satzanbau.", 28, 35, 2, 8, 3, 9, 5, 10},
|
||||
{"carrot", "Gemüse", "Möhre", "Nantaise 2", "Daucus carota subsp. sativus", "Gleichmäßige, stumpfe Wurzeln; das Saatbeet bis zur Keimung feucht halten.", 5, 25, 3, 7, 0, 0, 6, 11},
|
||||
{"chard", "Gemüse", "Mangold", "Bright Lights", "Beta vulgaris subsp. vulgaris", "Bunter Stielmangold für die fortlaufende Ernte der äußeren Blätter.", 35, 60, 3, 6, 4, 7, 6, 11},
|
||||
{"rosemary", "Kraut", "Rosmarin", "Arp", "Salvia rosmarinus", "Relativ winterharte Sorte für den durchlässigen Platz an der warmen Mauer.", 70, 120, 3, 5, 4, 6, 4, 10},
|
||||
{"currant", "Obst", "Rote Johannisbeere", "Rovada", "Ribes rubrum", "Späte Sorte mit langen Trauben, als dreitriebiger Strauch erzogen.", 140, 160, 0, 0, 10, 3, 7, 8},
|
||||
{"sunflower", "Blume", "Sonnenblume", "Velvet Queen", "Helianthus annuus", "Dunkelrote, verzweigte Sonnenblume für Bestäuber und Schnittblumen.", 45, 180, 4, 5, 5, 6, 8, 10},
|
||||
}
|
||||
for _, row := range rows {
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO species (
|
||||
garden_id, category_id, common_name, cultivar, botanical_name,
|
||||
sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm,
|
||||
sow_month_from, sow_month_to, planting_month_from, planting_month_to,
|
||||
harvest_month_from, harvest_month_to, notes, created_by, updated_by)
|
||||
SELECT $1, id, $2, $3, $4, 'sunny', 'moist', 'neutral', $5, $6,
|
||||
NULLIF($7, 0), NULLIF($8, 0), NULLIF($9, 0), NULLIF($10, 0),
|
||||
NULLIF($11, 0), NULLIF($12, 0), $13, $14, $14
|
||||
FROM species_categories WHERE name = $15
|
||||
RETURNING id`, gardenID, row.commonName, row.cultivar, row.botanicalName,
|
||||
row.spacing, row.height, row.sowFrom, row.sowTo, row.plantFrom, row.plantTo,
|
||||
row.harvestFrom, row.harvestTo, row.notes, userID, row.category).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create demo species %q: %w", row.commonName, err)
|
||||
}
|
||||
ids[row.key] = id
|
||||
}
|
||||
|
||||
type speciesDetails struct {
|
||||
key, sun, soil, reaction, winter, attributes, imageKey string
|
||||
sowFromDay, sowToDay, plantFromDay, plantToDay int
|
||||
harvestFromDay, harvestToDay int
|
||||
}
|
||||
details := []speciesDetails{
|
||||
{"tomato", "sunny", "moist", "neutral", "Frostfrei kultivieren; erst nach den Eisheiligen auspflanzen.", `{"saatgut":"bio","charge":"SM-2026-02","keimtemperatur_c":24,"partnerpflanzen":["Basilikum","Tagetes"]}`, "tomato", 15, 15, 15, 15, 1, 15},
|
||||
{"zucchini", "sunny", "moist", "neutral", "Bei kalten Nächten mit Vlies schützen.", `{"saatgut":"samenfest","farbe":"grün gestreift","starkzehrer":true}`, "", 1, 15, 15, 15, 20, 10},
|
||||
{"basil", "sunny", "moist", "neutral", "Unter 10 °C ins Haus oder Gewächshaus holen.", `{"verwendung":["Pesto","Tomatensalat"],"duft":"würzig","einjährig":true}`, "", 1, 15, 15, 15, 1, 30},
|
||||
{"strawberry", "sunny", "moist", "acidic", "Im Winter mit Laub mulchen; Herzblätter frei lassen.", `{"geschmack":"walderdbeerartig","befruchter_benoetigt":true,"standjahr":2}`, "", 0, 0, 1, 31, 20, 10},
|
||||
{"lavender", "sunny", "dry", "alkaline", "Vor Winternässe schützen; keine dichte Laubabdeckung.", `{"bluetenfarbe":"dunkelblau","insektenwert":"hoch","duft":true}`, "", 1, 31, 15, 30, 1, 31},
|
||||
{"apple", "sunny", "moist", "neutral", "Stamm in den ersten Jahren mit Weißanstrich vor Frostrissen schützen.", `{"unterlage":"M7","wuchsform":"Halbstamm","pflanzjahr":2024,"lagerfaehig":true}`, "apple", 0, 0, 1, 31, 20, 31},
|
||||
{"lettuce", "partial_shade", "moist", "neutral", "Bei starkem Frost mit Vlies abdecken.", `{"satzanbau":true,"schossfest":"mittel","ernte":"Blatt oder Kopf"}`, "", 15, 15, 15, 15, 1, 15},
|
||||
{"carrot", "sunny", "moist", "neutral", "Späte Sätze mit Stroh abdecken oder frostfrei einlagern.", `{"kulturzeit_tage":105,"saatband":false,"lagerung":"kühl und sandig"}`, "", 1, 15, 0, 0, 15, 15},
|
||||
{"chard", "sunny", "moist", "neutral", "Wurzelstock anhäufeln und mit Reisig schützen.", `{"stiele":["gelb","orange","rot","rosa"],"mehrfachernte":true}`, "", 15, 30, 15, 15, 1, 30},
|
||||
{"rosemary", "sunny", "dry", "alkaline", "Topf mit Jute umwickeln und bei Kahlfrost mit Vlies beschatten.", `{"winterhaerte_c":-18,"immergruen":true,"verwendung":"Küche und Duft"}`, "", 1, 31, 15, 30, 1, 31},
|
||||
{"currant", "partial_shade", "moist", "neutral", "Winterhart; Wurzelbereich im Herbst mit Kompost mulchen.", `{"erziehungsform":"Strauch","triebe":3,"netzeinsatz":true}`, "", 0, 0, 1, 31, 1, 15},
|
||||
{"sunflower", "sunny", "moist", "neutral", "Jungpflanzen vor Spätfrost schützen.", `{"bluetenfarbe":"dunkelrot","verzweigt":true,"insektenwert":"hoch"}`, "", 1, 31, 15, 15, 1, 15},
|
||||
}
|
||||
for _, detail := range details {
|
||||
var imageID any
|
||||
if detail.imageKey != "" {
|
||||
imageID = images[detail.imageKey]
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE species SET sun_exposure=$2, soil_condition=$3, soil_reaction=$4,
|
||||
winter_protection=$5, attributes=$6::jsonb, image_id=$7,
|
||||
sow_day_from=NULLIF($8,0), sow_day_to=NULLIF($9,0),
|
||||
planting_day_from=NULLIF($10,0), planting_day_to=NULLIF($11,0),
|
||||
harvest_day_from=NULLIF($12,0), harvest_day_to=NULLIF($13,0)
|
||||
WHERE id=$1`, ids[detail.key], detail.sun, detail.soil, detail.reaction, detail.winter,
|
||||
detail.attributes, imageID, detail.sowFromDay, detail.sowToDay, detail.plantFromDay,
|
||||
detail.plantToDay, detail.harvestFromDay, detail.harvestToDay); err != nil {
|
||||
return nil, nil, fmt.Errorf("complete demo species %q: %w", detail.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO care_instructions (species_id, text, status, created_by, updated_by) VALUES
|
||||
($1, 'Morgens bodennah gießen und Blätter trocken halten.', 'good', $7, $7),
|
||||
($1, 'Seitentriebe regelmäßig ausgeizen und locker anbinden.', 'testing', $7, $7),
|
||||
($2, 'Früchte ab etwa 15 cm Länge laufend ernten.', 'good', $7, $7),
|
||||
($3, 'Nur bei trockenem Boden gießen; Staunässe vermeiden.', 'good', $7, $7),
|
||||
($4, 'Nach der Ernte alte Blätter entfernen und mit Kompost versorgen.', 'planned', $7, $7),
|
||||
($5, 'Nach der Hauptblüte höchstens um ein Drittel einkürzen.', 'untested', $7, $7),
|
||||
($6, 'In längeren Trockenphasen einmal pro Woche durchdringend wässern.', 'testing', $7, $7)`,
|
||||
ids["tomato"], ids["zucchini"], ids["lavender"], ids["strawberry"], ids["basil"], ids["apple"], userID); err != nil {
|
||||
return nil, nil, fmt.Errorf("create demo care instructions: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO care_instructions (species_id, text, status, created_by, updated_by) VALUES
|
||||
($1, 'Morgens ernten und bei Hitze mit einem Schattiernetz vor dem Schossen schützen.', 'testing', $7, $7),
|
||||
($2, 'Saatrillen bis zur Keimung gleichmäßig feucht halten und früh vereinzeln.', 'good', $7, $7),
|
||||
($3, 'Immer nur die äußeren Blätter abbrechen; das Herz stehen lassen.', 'good', $7, $7),
|
||||
($4, 'Sparsam gießen und überschüssiges Wasser aus dem Untersetzer entfernen.', 'good', $7, $7),
|
||||
($5, 'Nach der Ernte alte Fruchttriebe bodennah entfernen.', 'planned', $7, $7),
|
||||
($6, 'Junge Pflanzen rechtzeitig an einen stabilen Stab anbinden.', 'untested', $7, $7)`,
|
||||
ids["lettuce"], ids["carrot"], ids["chard"], ids["rosemary"], ids["currant"], ids["sunflower"], userID); err != nil {
|
||||
return nil, nil, fmt.Errorf("create extended demo care instructions: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
|
||||
($1, 'species', $2, $3, $6),
|
||||
($1, 'species', $4, $5, $6)`, gardenID, ids["tomato"], images["tomato"], ids["apple"], images["apple"], userID); err != nil {
|
||||
return nil, nil, fmt.Errorf("record demo species images: %w", err)
|
||||
}
|
||||
|
||||
templates := make(map[string]int64)
|
||||
templateRows := []struct {
|
||||
key, species, title, description, trigger string
|
||||
monthFrom, monthTo, offsetFrom, offsetTo any
|
||||
priority int
|
||||
}{
|
||||
{"tomato_fertilize", "tomato", "Tomaten düngen", "Organischen Flüssigdünger sparsam dosieren.", "relative_to_planting", nil, nil, 21, 28, 3},
|
||||
{"tomato_harvest", "tomato", "Tomaten ernten", "Reife Früchte pflücken und Schadstellen kontrollieren.", "month_of_year", 7, 10, nil, nil, 0},
|
||||
{"zucchini_harvest", "zucchini", "Zucchini ernten", "Früchte bei 15 bis 20 cm Länge mit einem sauberen Messer schneiden.", "month_of_year", 6, 10, nil, nil, 0},
|
||||
{"basil_pinching", "basil", "Basilikumspitzen ernten", "Triebspitzen oberhalb eines Blattpaares abschneiden.", "relative_to_last_task", nil, nil, 7, 10, 0},
|
||||
{"strawberry_mulch", "strawberry", "Erdbeeren mulchen", "Stroh unter die Fruchtstände legen.", "month_of_year", 5, 5, nil, nil, 0},
|
||||
{"apple_prune", "apple", "Apfelbaum schneiden", "Krone auslichten und steile Wasserschosse entfernen.", "month_of_year", 2, 3, nil, nil, 3},
|
||||
{"lettuce_water", "lettuce", "Salat kontrollieren und gießen", "Bodenfeuchte prüfen und bei Bedarf morgens wässern.", "relative_to_sowing", nil, nil, 3, 5, 3},
|
||||
{"carrot_thin", "carrot", "Möhren vereinzeln", "Schwächere Sämlinge entfernen und fünf Zentimeter Abstand herstellen.", "relative_to_sowing", nil, nil, 18, 24, 0},
|
||||
{"rosemary_winter", "rosemary", "Rosmarin winterfest machen", "Topf schützen und Regenschutz kontrollieren.", "month_of_year", 11, 12, nil, nil, 3},
|
||||
{"currant_prune", "currant", "Johannisbeere auslichten", "Einen alten Haupttrieb entfernen und Jungtriebe anbinden.", "relative_to_harvest", nil, nil, 7, 21, 0},
|
||||
{"sunflower_support", "sunflower", "Sonnenblumen stützen", "Stäbe kontrollieren und Bindung locker nachführen.", "relative_to_species_planting", nil, nil, 14, 21, 0},
|
||||
}
|
||||
for _, row := range templateRows {
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO species_task_templates (
|
||||
species_id, title, description, trigger_type, month_from, month_to,
|
||||
offset_days_from, offset_days_to, priority)
|
||||
VALUES ($1, $2, $3, $4::task_trigger_type, $5, $6, $7, $8, $9)
|
||||
RETURNING id`, ids[row.species], row.title, row.description, row.trigger,
|
||||
row.monthFrom, row.monthTo, row.offsetFrom, row.offsetTo, row.priority).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create demo task template %q: %w", row.title, err)
|
||||
}
|
||||
templates[row.key] = id
|
||||
}
|
||||
type templateDetails struct {
|
||||
key, recurrence, unit, durationUnit string
|
||||
interval, triggerOffset, duration int
|
||||
}
|
||||
templateUpdates := []templateDetails{
|
||||
{"tomato_fertilize", "weekly", "day", "day", 2, 21, 1},
|
||||
{"tomato_harvest", "weekly", "day", "day", 1, 0, 2},
|
||||
{"zucchini_harvest", "weekly", "day", "day", 1, 0, 2},
|
||||
{"basil_pinching", "weekly", "day", "day", 1, 7, 1},
|
||||
{"strawberry_mulch", "yearly", "day", "week", 1, 0, 1},
|
||||
{"apple_prune", "yearly", "week", "week", 1, 0, 2},
|
||||
{"lettuce_water", "weekly", "day", "day", 1, 3, 1},
|
||||
{"carrot_thin", "", "day", "day", 1, 18, 2},
|
||||
{"rosemary_winter", "yearly", "day", "week", 1, 0, 2},
|
||||
{"currant_prune", "yearly", "week", "week", 1, 1, 2},
|
||||
{"sunflower_support", "weekly", "week", "day", 1, 2, 1},
|
||||
}
|
||||
for _, detail := range templateUpdates {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE species_task_templates SET recurrence=$2, recurrence_interval=$3,
|
||||
trigger_offset=$4, trigger_offset_unit=$5, duration=$6, duration_unit=$7,
|
||||
interval_days=CASE WHEN $2='weekly' THEN 7 ELSE NULL END
|
||||
WHERE id=$1`, templates[detail.key], detail.recurrence, detail.interval,
|
||||
detail.triggerOffset, detail.unit, detail.duration, detail.durationUnit); err != nil {
|
||||
return nil, nil, fmt.Errorf("complete demo task template %q: %w", detail.key, err)
|
||||
}
|
||||
}
|
||||
return ids, templates, nil
|
||||
}
|
||||
|
||||
func seedDemoPlants(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, locations, species, templates, images map[string]int64) (map[string]int64, error) {
|
||||
ids := make(map[string]int64)
|
||||
type plantSeed struct {
|
||||
key, species, name, notes, location, status, attributes, imageKey string
|
||||
quantity, ageDays, removedDays int
|
||||
owner int64
|
||||
}
|
||||
rows := []plantSeed{
|
||||
{"tomato", "tomato", "Tomatenreihe am Fenster", "Vier kräftige Pflanzen, zweitriebig gezogen. Pflanze zwei trägt bereits den ersten Fruchtstand.", "greenhouse", "alive", `{"anzucht":"Fensterbank","topfgroesse_l":12,"triebzahl":2,"messpunkt":"GH-T1"}`, "tomato", 4, 92, 0, demoUserID},
|
||||
{"zucchini", "zucchini", "Zucchini im Hochbeet", "Die westliche Pflanze ist etwas kleiner, bildet aber neue Blätter.", "raised", "alive", `{"pflanzen":["Ost","West"],"mulch":"Rasenschnitt","erste_bluete":true}`, "", 2, 70, 0, memberID},
|
||||
{"basil", "basil", "Basilikum zwischen den Tomaten", "Mischkultur für Küche und Gewächshausklima.", "greenhouse", "alive", `{"ernteintervall_tage":7,"letzte_ernte":"vor 4 Tagen"}`, "", 6, 48, 0, demoUserID},
|
||||
{"strawberry", "strawberry", "Erdbeerreihe", "Im Frühjahr mit Kompost versorgt und mit Stroh unterlegt.", "vegetable", "alive", `{"standjahr":2,"reihenlaenge_m":4.5,"bewässerung":"Tropfschlauch"}`, "", 12, 410, 0, memberID},
|
||||
{"lavender", "lavender", "Lavendel an der Mauer", "Wichtige Insektenweide am Hauptweg; Rückschnitt nach der Blüte vormerken.", "herbs", "alive", `{"pflanzabstand_cm":45,"bienenbesuche":"sehr häufig"}`, "", 3, 760, 0, demoUserID},
|
||||
{"apple", "apple", "Apfelbaum am Nordrand", "Baumscheibe frei halten; Bindung am Pfahl monatlich kontrollieren.", "orchard", "alive", `{"stammumfang_cm":12,"baumpfahl":true,"giessring_l":60}`, "apple", 1, 980, 0, demoUserID},
|
||||
{"seedling", "", "Überraschungssämling", "Noch ohne bestimmte Art. Blattform und Entwicklung weiter fotografisch dokumentieren.", "herbs", "alive", `{"bestimmung":"offen","fundort":"Kompostrand","markierung":"gelber Stab"}`, "", 1, 24, 0, memberID},
|
||||
{"lettuce", "lettuce", "Salatsatz Juni", "Gestaffelt gepflanzt; die äußeren Blätter können bereits geerntet werden.", "north_bed", "alive", `{"satz":3,"jungpflanzenquelle":"eigene Anzucht","schattiernetz":true}`, "", 18, 38, 0, memberID},
|
||||
{"carrots", "carrot", "Möhrenreihe am Rand", "Keimung war lückig; nach dem Vereinzeln sind fünf Zentimeter Abstand geplant.", "raised", "alive", `{"reihen":2,"reihenabstand_cm":25,"markiersaat":"Radieschen"}`, "", 36, 54, 0, demoUserID},
|
||||
{"chard", "chard", "Bunter Mangold", "Vier Farbtypen gemischt, regelmäßig nur außen beernten.", "north_bed", "alive", `{"farben":["gelb","orange","rot","rosa"],"überwinterungsversuch":true}`, "", 9, 110, 0, demoUserID},
|
||||
{"rosemary", "rosemary", "Rosmarin im Terrakottatopf", "Steht regengeschützt direkt an der warmen Natursteinmauer.", "herbs", "alive", `{"topf_l":35,"substrat":"mineralisch","mobil":true}`, "", 1, 620, 0, memberID},
|
||||
{"currant", "currant", "Johannisbeerstrauch Rovada", "Drei Haupttriebe, Beerennetz wird erst bei beginnender Reife aufgelegt.", "orchard", "alive", `{"haupttriebe":3,"ernteprognose_kg":2.5,"netz":"eingelagert"}`, "", 1, 830, 0, demoUserID},
|
||||
{"sunflowers", "sunflower", "Sonnenblumen am Zaun", "Dunkelrote Blüten als Windschutz und Futterquelle für Insekten.", "vegetable", "alive", `{"reihe_m":6,"stützen":8,"schnittblumen":true}`, "", 14, 65, 0, memberID},
|
||||
{"infested_currant", "currant", "Johannisbeere mit Blattläusen", "An den jungen Triebspitzen sitzen Blattlauskolonien; Nützlingsaktivität beobachten.", "orchard", "infested", `{"schädling":"Blattlaus","befall":"leicht","maßnahme":"abwarten und Nützlinge fördern"}`, "", 1, 810, 0, memberID},
|
||||
{"old_zucchini", "zucchini", "Zucchini vom Vorjahr", "Abgeerntete Pflanze als Beispiel für eine abgeschlossene Saison.", "raised", "harvested", `{"ernte_kg":18.4,"letzte_frucht_cm":24,"kompostiert":true}`, "", 1, 430, 180, demoUserID},
|
||||
{"dead_lettuce", "lettuce", "Ausgefallener Frühjahrssalat", "Nach einem starken Schneckenfraß nicht wieder ausgetrieben.", "north_bed", "dead", `{"ursache":"Schneckenfraß","ersatzpflanzung":"Salatsatz Juni"}`, "", 6, 120, 72, memberID},
|
||||
}
|
||||
for _, row := range rows {
|
||||
var speciesID any
|
||||
if row.species != "" {
|
||||
speciesID = species[row.species]
|
||||
}
|
||||
var imageID any
|
||||
if row.imageKey != "" {
|
||||
imageID = images[row.imageKey]
|
||||
}
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO plants (garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_id, created_by, updated_by, planted_by)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_DATE - $5::integer, $6::plant_status,
|
||||
CASE WHEN $7 > 0 THEN CURRENT_DATE - $7::integer ELSE NULL END, $8::jsonb, $9, $10, $10, $10)
|
||||
RETURNING id`, gardenID, speciesID, row.name, row.notes, row.ageDays, row.status, row.removedDays, row.attributes, imageID, row.owner).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create demo plant %q: %w", row.name, err)
|
||||
}
|
||||
ids[row.key] = id
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO plant_locations (plant_id, location_id, quantity, planted_at, removed_at, notes)
|
||||
VALUES ($1, $2, $3, CURRENT_DATE - $4::integer,
|
||||
CASE WHEN $5 > 0 THEN CURRENT_DATE - $5::integer ELSE NULL END,
|
||||
CASE WHEN $5 > 0 THEN 'Zuordnung mit dem Pflanzenstatus abgeschlossen' ELSE 'Standort geprüft und dokumentiert' END)`,
|
||||
id, locations[row.location], row.quantity, row.ageDays, row.removedDays); err != nil {
|
||||
return nil, fmt.Errorf("place demo plant %q: %w", row.name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at)
|
||||
VALUES
|
||||
($1, 'alive', 'harvested', 'Saison beendet und letzte Früchte geerntet', CURRENT_DATE - 180),
|
||||
($2, 'alive', 'dead', 'Nach starkem Schneckenfraß nicht wieder ausgetrieben', CURRENT_DATE - 72),
|
||||
($3, 'alive', 'infested', 'Leichter Blattlausbefall an den Triebspitzen entdeckt', CURRENT_DATE - 4)`,
|
||||
ids["old_zucchini"], ids["dead_lettuce"], ids["infested_currant"]); err != nil {
|
||||
return nil, fmt.Errorf("create demo plant history: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
|
||||
($1, 'plant', $2, $3, $6),
|
||||
($1, 'plant', $4, $5, $6)`, gardenID, ids["tomato"], images["tomato"], ids["apple"], images["apple"], demoUserID); err != nil {
|
||||
return nil, fmt.Errorf("record demo plant images: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO task_template_opt_outs (plant_id, template_id)
|
||||
VALUES ($1, $2)`, ids["rosemary"], templates["rosemary_winter"]); err != nil {
|
||||
return nil, fmt.Errorf("create demo task template opt-out: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func seedDemoTasks(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, locations, plants, templates map[string]int64) (int, error) {
|
||||
type taskSeed struct {
|
||||
key, title, description, plant, location, template, recurrence, repeatFrom, statusOnCompletion string
|
||||
startDays, endDays, priority, recurrenceInterval int
|
||||
completed, active bool
|
||||
owner int64
|
||||
}
|
||||
rows := []taskSeed{
|
||||
{"vent_previous", "Gewächshaus lüften", "Beide Türen öffnen, Dachfenster prüfen und abends wieder schließen.", "", "greenhouse", "", "daily", "", "", -1, -1, 0, 1, true, true, demoUserID},
|
||||
{"vent_today", "Gewächshaus lüften", "Morgens Temperatur und Luftfeuchte notieren, anschließend querlüften.", "", "greenhouse", "", "daily", "vent_previous", "", 0, 0, 0, 1, false, true, demoUserID},
|
||||
{"tomato_prune", "Tomaten ausgeizen", "Seitentriebe unterhalb des ersten Fruchtstands entfernen und Schnüre nachspannen.", "tomato", "greenhouse", "", "weekly", "", "", -3, -1, 5, 1, false, true, demoUserID},
|
||||
{"raised_water", "Hochbeet gründlich gießen", "Am frühen Morgen etwa 15 Liter langsam unter der Mulchschicht verteilen.", "", "raised", "", "", "", "", 0, 0, 3, 1, false, true, memberID},
|
||||
{"tomato_fertilize", "Tomaten düngen", "Organischen Flüssigdünger 1:100 dosieren und nur auf bereits feuchten Boden geben.", "tomato", "greenhouse", "tomato_fertilize", "weekly", "", "", 2, 4, 3, 2, false, true, demoUserID},
|
||||
{"strawberry_check", "Erdbeeren auf Schnecken prüfen", "Besonders unter dem Stroh kontrollieren und reife Früchte direkt ernten.", "strawberry", "vegetable", "", "weekly", "", "", 5, 7, 0, 1, false, true, memberID},
|
||||
{"apple_water", "Apfelbaum wässern", "Sechzig Liter langsam im Gießring verteilen und die Baumbindung kontrollieren.", "apple", "orchard", "", "weekly", "", "", 9, 9, 3, 1, false, true, demoUserID},
|
||||
{"lettuce_water", "Salat kontrollieren und gießen", "Bodenfeuchte prüfen, äußere erntereife Blätter schneiden und Läuse kontrollieren.", "lettuce", "north_bed", "lettuce_water", "weekly", "", "", 1, 2, 3, 1, false, true, memberID},
|
||||
{"carrot_thin", "Möhren vereinzeln", "Auf fünf Zentimeter Abstand ausdünnen und anschließend vorsichtig einschlämmen.", "carrots", "raised", "carrot_thin", "", "", "", 3, 5, 0, 1, false, true, demoUserID},
|
||||
{"chard_harvest", "Mangold ernten", "Je Pflanze zwei große Außenblätter mit sauberem Messer schneiden.", "chard", "north_bed", "", "weekly", "", "", 4, 4, 0, 1, false, true, memberID},
|
||||
{"aphid_check", "Blattlausbefall kontrollieren", "Triebspitzen fotografieren, Marienkäferlarven zählen und Befallsstärke dokumentieren.", "infested_currant", "orchard", "", "daily", "", "", 0, 2, 5, 1, false, true, memberID},
|
||||
{"compost_turn", "Kompost umsetzen", "Material aus Kammer zwei mischen, Feuchtigkeit prüfen und Temperatur messen.", "", "compost", "", "monthly", "", "", 12, 14, 3, 1, false, true, demoUserID},
|
||||
{"pond_level", "Wasserstand im Miniteich prüfen", "Nur aufgefangenes Regenwasser nachfüllen und die Ausstiegshilfe freihalten.", "", "pond", "", "weekly", "", "", 6, 6, 0, 1, false, true, memberID},
|
||||
{"herbs_weed", "Kräuterecke jäten", "Quecke vollständig mit Wurzeln entfernen und freien Boden mit Splitt abdecken.", "", "herbs", "", "monthly", "", "", -8, -7, 0, 1, true, true, memberID},
|
||||
{"zucchini_harvest", "Zucchini ernten", "Sechs junge Früchte geerntet und das Gesamtgewicht im Gartentagebuch notiert.", "zucchini", "raised", "zucchini_harvest", "weekly", "", "", -6, -5, 0, 1, true, true, demoUserID},
|
||||
{"old_zucchini_close", "Zucchinisaison abschließen", "Letzte Frucht ernten, Pflanze entfernen und gesundes Material kompostieren.", "old_zucchini", "raised", "", "", "", "harvested", -182, -180, 0, 1, true, false, demoUserID},
|
||||
}
|
||||
ids := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
var plantID, locationID, templateID, repeatFromID any
|
||||
if row.plant != "" {
|
||||
plantID = plants[row.plant]
|
||||
}
|
||||
if row.location != "" {
|
||||
locationID = locations[row.location]
|
||||
}
|
||||
if row.template != "" {
|
||||
templateID = templates[row.template]
|
||||
}
|
||||
if row.repeatFrom != "" {
|
||||
repeatFromID = ids[row.repeatFrom]
|
||||
}
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO tasks (
|
||||
garden_id, plant_id, location_id, template_id, title, description,
|
||||
due_at_start, due_at_end, generated_for, completed_at, completed_by,
|
||||
priority, active, recurrence, recurrence_interval, repeat_from_id,
|
||||
plant_status_on_completion, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6,
|
||||
CURRENT_DATE + $7::integer, CURRENT_DATE + $8::integer,
|
||||
CASE WHEN $4::bigint IS NULL THEN NULL ELSE CURRENT_DATE END,
|
||||
CASE WHEN $9 THEN CURRENT_TIMESTAMP - interval '6 days' ELSE NULL END,
|
||||
CASE WHEN $9 THEN $10::bigint ELSE NULL END,
|
||||
$11, $12, $13, $14, $15, NULLIF($16, '')::plant_status, $10)
|
||||
RETURNING id`,
|
||||
gardenID, plantID, locationID, templateID, row.title, row.description,
|
||||
row.startDays, row.endDays, row.completed, row.owner, row.priority, row.active,
|
||||
row.recurrence, row.recurrenceInterval, repeatFromID, row.statusOnCompletion).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create demo task %q: %w", row.title, err)
|
||||
}
|
||||
ids[row.key] = id
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tags (garden_id, name) VALUES
|
||||
($1, 'Dringend'), ($1, 'Bewässerung'), ($1, 'Ernte'),
|
||||
($1, 'Beobachtung'), ($1, 'Gemeinschaft'), ($1, 'Nützlinge'), ($1, 'Gewächshaus')`, gardenID); err != nil {
|
||||
return 0, fmt.Errorf("create demo tags: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO task_tags (task_id, tag_id)
|
||||
SELECT tasks.id, tags.id FROM tasks JOIN tags ON tags.garden_id=tasks.garden_id
|
||||
WHERE tasks.garden_id=$1 AND (
|
||||
(tasks.title='Tomaten ausgeizen' AND tags.name IN ('Dringend','Gewächshaus')) OR
|
||||
(tasks.title='Hochbeet gründlich gießen' AND tags.name='Bewässerung') OR
|
||||
(tasks.title='Apfelbaum wässern' AND tags.name='Bewässerung') OR
|
||||
(tasks.title='Blattlausbefall kontrollieren' AND tags.name IN ('Dringend','Beobachtung','Nützlinge')) OR
|
||||
(tasks.title='Zucchini ernten' AND tags.name='Ernte'))`, gardenID); err != nil {
|
||||
return 0, fmt.Errorf("tag demo tasks: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plant_tags (plant_id, tag_id)
|
||||
SELECT plants.id, tags.id FROM plants JOIN tags ON tags.garden_id=plants.garden_id
|
||||
WHERE plants.garden_id=$1 AND (
|
||||
(plants.name='Erdbeerreihe' AND tags.name='Ernte') OR
|
||||
(plants.name='Johannisbeere mit Blattläusen' AND tags.name IN ('Beobachtung','Nützlinge')) OR
|
||||
(plants.name='Tomatenreihe am Fenster' AND tags.name='Gewächshaus'))`, gardenID); err != nil {
|
||||
return 0, fmt.Errorf("tag demo plants: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
func seedDemoJournal(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, images map[string]int64) (int, int, error) {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO journal_entries (garden_id, author_id, entry_type, title, body, created_at, updated_at) VALUES
|
||||
($1, $2, 'pinboard', 'Willkommen im Sonnengarten', E'Diese Demo zeigt einen vollständig gepflegten Gemeinschaftsgarten.\n\n**Zum Ausprobieren:** Aufgaben filtern, Pflanzen nach Standort öffnen, Pflegehinweise bewerten und im Tagebuch stöbern. Die Daten werden stündlich zurückgesetzt.', CURRENT_TIMESTAMP - interval '21 days', CURRENT_TIMESTAMP - interval '20 days'),
|
||||
($1, $3, 'pinboard', 'Gießdienst am Wochenende', E'Für Samstag sind **28 °C** angekündigt. Bitte zuerst Gewächshaus und Hochbeet versorgen, danach die Gießringe auf der Obstwiese.\n\nDie Kräuterecke nur gießen, wenn Rosmarin und Lavendel deutlich schlapp wirken.', CURRENT_TIMESTAMP - interval '3 days', CURRENT_TIMESTAMP - interval '2 days'),
|
||||
($1, $3, 'journal', 'Die ersten Erdbeeren', E'Heute konnten wir die ersten reifen Erdbeeren probieren. Das Stroh hält die Früchte sauber.\n\n- Ernte: 620 g\n- Geschmack: sehr aromatisch\n- Beobachtung: zwei Früchte mit Vogelfraß\n\nDas Netz bringen wir erst an, wenn der Schaden zunimmt.', CURRENT_TIMESTAMP - interval '12 days', CURRENT_TIMESTAMP - interval '12 days'),
|
||||
($1, $2, 'journal', 'Tomaten wachsen gut an', E'Alle vier Pflanzen haben neue Blätter und kräftige Seitentriebe gebildet. Pflanze zwei trägt den ersten Fruchtstand.\n\nDie Tropfbewässerung läuft morgens für zwanzig Minuten. Beim nächsten Termin werden die Schnüre nachgespannt und die unteren Blätter ausgelichtet.', CURRENT_TIMESTAMP - interval '7 days', CURRENT_TIMESTAMP - interval '6 days'),
|
||||
($1, $3, 'journal', 'Nützlinge an der Johannisbeere', E'Am befallenen Strauch wurden heute **vier Marienkäferlarven** und mehrere Schwebfliegen beobachtet. Deshalb verzichten wir vorerst auf eine Behandlung.\n\nIn zwei Tagen vergleichen wir Fotos derselben markierten Triebspitze.', CURRENT_TIMESTAMP - interval '2 days', CURRENT_TIMESTAMP - interval '2 days'),
|
||||
($1, $2, 'journal', 'Komposttemperatur steigt', E'Kammer zwei erreicht nach dem Umsetzen 54 °C. Das Material ist feucht wie ein ausgedrückter Schwamm und riecht angenehm erdig.\n\nBeim nächsten Umsetzen trockenes Häckselmaterial aus Kammer eins beimischen.', CURRENT_TIMESTAMP - interval '1 day', CURRENT_TIMESTAMP - interval '1 day')`,
|
||||
gardenID, demoUserID, memberID); err != nil {
|
||||
return 0, 0, fmt.Errorf("create demo journal entries: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO journal_entry_tags (journal_entry_id, tag_id)
|
||||
SELECT journal_entries.id, tags.id
|
||||
FROM journal_entries JOIN tags ON tags.garden_id = journal_entries.garden_id
|
||||
WHERE journal_entries.garden_id = $1
|
||||
AND ((journal_entries.title = 'Die ersten Erdbeeren' AND tags.name = 'Ernte')
|
||||
OR (journal_entries.title = 'Tomaten wachsen gut an' AND tags.name IN ('Beobachtung','Gewächshaus'))
|
||||
OR (journal_entries.title = 'Willkommen im Sonnengarten' AND tags.name = 'Gemeinschaft')
|
||||
OR (journal_entries.title = 'Gießdienst am Wochenende' AND tags.name IN ('Gemeinschaft','Bewässerung'))
|
||||
OR (journal_entries.title = 'Nützlinge an der Johannisbeere' AND tags.name IN ('Beobachtung','Nützlinge'))
|
||||
OR (journal_entries.title = 'Komposttemperatur steigt' AND tags.name = 'Beobachtung'))`, gardenID); err != nil {
|
||||
return 0, 0, fmt.Errorf("tag demo journal entries: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO species_tags (species_id, tag_id)
|
||||
SELECT species.id, tags.id FROM species JOIN tags ON tags.garden_id=species.garden_id
|
||||
WHERE species.garden_id=$1 AND (
|
||||
(species.common_name IN ('Tomate','Basilikum') AND tags.name='Gewächshaus') OR
|
||||
(species.common_name IN ('Erdbeere','Zucchini','Rote Johannisbeere') AND tags.name='Ernte') OR
|
||||
(species.common_name IN ('Lavendel','Sonnenblume') AND tags.name='Nützlinge'))`, gardenID); err != nil {
|
||||
return 0, 0, fmt.Errorf("tag demo species: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO journal_attachments (journal_entry_id, image_id, file_name, media_type, data, size)
|
||||
SELECT id, $2, 'tomaten-gewaechshaus.jpg', 'image/jpeg', $3, $4
|
||||
FROM journal_entries WHERE garden_id=$1 AND title='Tomaten wachsen gut an'`,
|
||||
gardenID, images["tomato"], demoTomatoImage, len(demoTomatoImage)); err != nil {
|
||||
return 0, 0, fmt.Errorf("create demo journal attachment: %w", err)
|
||||
}
|
||||
return 6, 1, nil
|
||||
}
|
||||
@@ -49,6 +49,24 @@ type createUserResult struct {
|
||||
Token *auth.Token
|
||||
}
|
||||
|
||||
type demoResetInput struct {
|
||||
Name string
|
||||
Email string
|
||||
PasswordHash []byte
|
||||
}
|
||||
|
||||
type demoResetResult struct {
|
||||
Email string `json:"email"`
|
||||
Garden string `json:"garden"`
|
||||
Images int `json:"images"`
|
||||
Locations int `json:"locations"`
|
||||
Species int `json:"species"`
|
||||
Plants int `json:"plants"`
|
||||
Tasks int `json:"tasks"`
|
||||
Journal int `json:"journal_entries"`
|
||||
Attachments int `json:"attachments"`
|
||||
}
|
||||
|
||||
type adminStore interface {
|
||||
Ping(context.Context) error
|
||||
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
||||
@@ -59,6 +77,7 @@ type adminStore interface {
|
||||
ResetPassword(context.Context, string, []byte) (userView, error)
|
||||
SetUserRole(context.Context, string, string) (userView, error)
|
||||
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
||||
ResetDemo(context.Context, demoResetInput) (demoResetResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -4,8 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/environment"
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"gardomatic.kleiax.de/internal/web"
|
||||
)
|
||||
|
||||
@@ -18,7 +21,7 @@ func configFromEnvironment() (web.Config, error) {
|
||||
if err != nil {
|
||||
return web.Config{}, err
|
||||
}
|
||||
cfg := web.Config{Host: environment.String("GARDOMATIC_WEB_HOST", ""), Port: port, Env: environment.String("GARDOMATIC_ENV", "development"), APIBaseURL: environment.String("GARDOMATIC_API_BASE_URL", "http://localhost:4000"), SessionCookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: cookieSecure}
|
||||
cfg := web.Config{Host: environment.String("GARDOMATIC_WEB_HOST", ""), Port: port, Env: environment.String("GARDOMATIC_ENV", "development"), APIBaseURL: environment.String("GARDOMATIC_API_BASE_URL", "http://localhost:4000"), DemoAccountEmail: strings.ToLower(strings.TrimSpace(environment.String("GARDOMATIC_DEMO_ACCOUNT_EMAIL", ""))), SessionCookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: cookieSecure}
|
||||
var errs []error
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 65535"))
|
||||
@@ -30,5 +33,11 @@ func configFromEnvironment() (web.Config, error) {
|
||||
if cfg.Env == "production" && !cfg.CookieSecure {
|
||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
||||
}
|
||||
if cfg.DemoAccountEmail != "" {
|
||||
v := validate.New()
|
||||
if storage.ValidateEmail(v, cfg.DemoAccountEmail); !v.Valid() {
|
||||
errs = append(errs, errors.New("GARDOMATIC_DEMO_ACCOUNT_EMAIL must be a valid email address"))
|
||||
}
|
||||
}
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,19 @@ func TestConfigFromEnvironment(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_WEB_HOST", "0.0.0.0")
|
||||
t.Setenv("GARDOMATIC_WEB_PORT", "4444")
|
||||
t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com")
|
||||
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", " Demo@Example.com ")
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" {
|
||||
if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" || cfg.DemoAccountEmail != "demo@example.com" {
|
||||
t.Fatalf("unexpected config: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsInvalidDemoAccountEmail(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", "not-an-email")
|
||||
if _, err := configFromEnvironment(); err == nil {
|
||||
t.Fatal("expected invalid demo account email error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user