commit 904d14b64c50c51b0937aea84fb8fc2eae85e49e
Author: Alexander Klein
Date: Sat Sep 12 22:22:17 2026 +0200
Initial commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5021857
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.env*
+.envrc
+config.mk
+/bin
+/dist
+/remote
+/request
+*.key
+*.pem
+*.p12
+*.pfx
+*.log
+*.out
+*.prof
+coverage.*
+**/__debug_bin*
+*.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4380346
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,70 @@
+# Copy this file to .env before starting the Compose stack. Do not commit .env.
+
+# PostgreSQL database and role name. Simple lowercase identifiers are recommended.
+POSTGRES_DB=gardomatic
+POSTGRES_USER=gardomatic
+# Required, non-empty password. If it contains URI-reserved characters, use the
+# percent-encoded form of the same password in GARDOMATIC_DB_DSN below.
+POSTGRES_PASSWORD=change-me
+# Host port published by Compose. Allowed: integer from 1 to 65535.
+POSTGRES_PORT=5432
+
+# Host ports published for API and web. Allowed: integers from 1 to 65535.
+API_PORT=4000
+WEB_PORT=4040
+
+# Runtime mode. Allowed: development, test, production.
+GARDOMATIC_ENV=development
+# Required PostgreSQL connection string. Supported form:
+# postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=MODE
+GARDOMATIC_DB_DSN=postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable
+# Connection-pool limits. Open must be at least 1; idle must be between 0 and open.
+GARDOMATIC_DB_MAX_OPEN_CONNS=25
+GARDOMATIC_DB_MAX_IDLE_CONNS=25
+# Go duration, for example 30s, 15m or 1h30m.
+GARDOMATIC_DB_MAX_IDLE_TIME=15m
+
+# Listener ports. Allowed: integers from 1 to 65535.
+GARDOMATIC_API_PORT=4000
+GARDOMATIC_WEB_PORT=4040
+# Listener hosts. Empty means all available interfaces; otherwise use an IP address
+# or resolvable hostname such as 127.0.0.1 or localhost.
+GARDOMATIC_API_HOST=
+GARDOMATIC_WEB_HOST=
+# Absolute HTTP(S) URLs including scheme and host. These are addresses used by the
+# applications, not listener addresses.
+GARDOMATIC_API_BASE_URL=http://localhost:4000
+GARDOMATIC_WEB_BASE_URL=http://localhost:4040
+
+# Non-empty cookie name shared by API and web.
+GARDOMATIC_SESSION_COOKIE_NAME=gardomatic_session
+# Go durations, for example 30m, 12h or 168h. Use positive values.
+GARDOMATIC_SESSION_LIFETIME=12h
+GARDOMATIC_SESSION_IDLE_TIMEOUT=30m
+# Boolean. Accepted true values: 1, t, T, TRUE, true, True. Accepted false
+# values: 0, f, F, FALSE, false, False. Must be true in production.
+GARDOMATIC_COOKIE_SECURE=false
+
+# Boolean with the same accepted forms as GARDOMATIC_COOKIE_SECURE.
+GARDOMATIC_RATE_LIMIT_ENABLED=true
+# Positive requests per second; decimal values such as 0.5 are accepted.
+GARDOMATIC_RATE_LIMIT_RPS=10
+# Positive integer defining the permitted request burst.
+GARDOMATIC_RATE_LIMIT_BURST=40
+# Comma-separated absolute URLs; every entry requires a scheme and host. Empty
+# disables cross-origin browser access. Use origins without paths, for example:
+# https://garden.example.com,https://admin.example.com
+GARDOMATIC_CORS_TRUSTED_ORIGINS=http://localhost:4040
+
+# Delivery mode. Allowed: file or smtp.
+GARDOMATIC_SMTP_MODE=file
+# SMTP hostname and TCP port. Used only in smtp mode.
+GARDOMATIC_SMTP_HOST=
+GARDOMATIC_SMTP_PORT=25
+# SMTP credentials. Used only in smtp mode; keep the password in .env only.
+GARDOMATIC_SMTP_USERNAME=
+GARDOMATIC_SMTP_PASSWORD=
+# Sender mailbox, for example gardomatic@example.com.
+GARDOMATIC_SMTP_SENDER=gardomatic@localhost
+# Writable absolute file path. Required only in file mode.
+GARDOMATIC_SMTP_FILE_PATH=/tmp/gardomatic-mails.log
diff --git a/.envrc.example b/.envrc.example
new file mode 100644
index 0000000..ea6b2a5
--- /dev/null
+++ b/.envrc.example
@@ -0,0 +1,7 @@
+# Copy this file to .envrc. Make targets source it as a shell file; direnv can
+# load it as well. Secrets may therefore be quoted normally for Bash.
+
+# Local development database.
+export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable'
+# Required only for integration tests. Always use a disposable test database.
+export GARDOMATIC_TEST_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic_test?sslmode=disable'
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..003fc71
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
+## Änderung
+
+
+
+## Prüfung
+
+
+
+- [ ] Betroffene Tests wurden ergänzt oder der Verzicht wurde begründet.
+- [ ] `go test ./...` wurde ausgeführt oder ein Fehlschlag wurde dokumentiert.
+- [ ] Dokumentation und Konfigurationsbeispiele wurden bei Bedarf aktualisiert.
+- [ ] Der Beitrag enthält kein undeklariertes Fremdmaterial und keine Geheimnisse.
+
+## Contributor License Agreement
+
+- [ ] **I have read and agree to the Gardomatic Contributor License Agreement,
+ Version 1.0 (`CLA.md`), and I have the authority to grant the rights stated
+ in it for this contribution.**
+
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8fd29c8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: gardomatic
+ POSTGRES_USER: gardomatic
+ POSTGRES_PASSWORD: gardomatic
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U gardomatic"
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 15
+ env:
+ GARDOMATIC_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable
+ GARDOMATIC_TEST_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: Install migrate
+ run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest
+ - name: Migrate database
+ run: migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_DB_DSN" up
+ - name: Verify module and source
+ run: |
+ go mod tidy -diff
+ go vet ./...
+ - name: Test with race detector
+ run: go test -race -count=1 ./...
+ - name: Build all binaries
+ run: make build/all
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
new file mode 100644
index 0000000..bfd889a
--- /dev/null
+++ b/.github/workflows/cla.yml
@@ -0,0 +1,22 @@
+name: CLA
+
+on:
+ pull_request:
+ types: [opened, edited, reopened, synchronize]
+
+permissions: {}
+
+jobs:
+ acceptance:
+ name: Verify CLA acceptance
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check pull request declaration
+ env:
+ PR_BODY: ${{ github.event.pull_request.body }}
+ run: |
+ declaration='^- \[[xX]\] \*\*I have read and agree to the Gardomatic Contributor License Agreement,'
+ if ! grep -Eq "$declaration" <<< "$PR_BODY"; then
+ echo "The pull request author must accept CLA.md using the checkbox in the pull request template."
+ exit 1
+ fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..97b5c5a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,36 @@
+# Environment files and secrets
+.env
+.env.*
+!.env.example
+!.env.*.example
+.envrc
+/config.mk
+*.key
+*.pem
+*.p12
+*.pfx
+remote/production/gardomatic.env
+
+# Local HTTP requests which may contain credentials
+request/*.local.http
+request/*.private.http
+
+# Build and test artifacts
+/bin/
+/dist/
+*.test
+*.out
+coverage.*
+*.prof
+**/__debug_bin*
+
+# Runtime files
+*.log
+/tmp/
+
+# Local editor and operating-system files
+.idea/
+.vscode/*
+!.vscode/launch.json
+.DS_Store
+Thumbs.db
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..b5d8a54
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,37 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Gardomatic: API",
+ "type": "go",
+ "request": "launch",
+ "mode": "debug",
+ "program": "${workspaceFolder}/cmd/api",
+ "envFile": "${workspaceFolder}/.env",
+ "env": {
+ "GARDOMATIC_API_HOST": "127.0.0.1"
+ }
+ },
+ {
+ "name": "Gardomatic: Web",
+ "type": "go",
+ "request": "launch",
+ "mode": "debug",
+ "program": "${workspaceFolder}/cmd/web",
+ "envFile": "${workspaceFolder}/.env",
+ "env": {
+ "GARDOMATIC_WEB_HOST": "0.0.0.0"
+ }
+ }
+ ],
+ "compounds": [
+ {
+ "name": "Gardomatic: API + Web",
+ "configurations": [
+ "Gardomatic: API",
+ "Gardomatic: Web"
+ ],
+ "stopAll": true
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..cf11b62
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,165 @@
+# AGENTS.md
+
+Diese Anweisungen gelten für das gesamte Repository. Benutzeranweisungen haben
+Vorrang. Spezifischere `AGENTS.md`- oder `AGENTS.override.md`-Dateien in
+Unterverzeichnissen dürfen diese Regeln für ihren Bereich ergänzen oder
+überschreiben.
+
+## Projektüberblick
+
+Gardomatic ist eine mobile, mehrbenutzerfähige Gartenverwaltung in Go. Das
+Repository enthält drei Programme:
+
+- `cmd/api`: JSON-API; besitzt Authentifizierung, Autorisierung und Fachlogik.
+- `cmd/web`: serverseitig gerenderte Webanwendung; greift ausschließlich über
+ `lib/client` auf die API zu.
+- `cmd/cli`: Administration über direkten PostgreSQL-Zugriff.
+
+PostgreSQL ist die einzige unterstützte Datenbank. Das Web-Frontend soll in den
+wesentlichen Abläufen ohne JavaScript funktionieren; htmx ergänzt diese Abläufe.
+
+## Arbeitsweise
+
+- Vor Änderungen zuerst den betroffenen Ablauf über alle Schichten verfolgen.
+ Relevante Implementierungen, Helfer und Tests mit `rg` suchen.
+- Änderungen klein und auf den Auftrag begrenzt halten. Keine beiläufigen
+ Umbenennungen, Formatierungen oder Refactorings außerhalb des betroffenen
+ Bereichs durchführen.
+- Bestehende, nicht zum Auftrag gehörende Änderungen im Arbeitsverzeichnis
+ erhalten und nicht überschreiben.
+- Fachliche Änderungen als vollständigen Vertical Slice umsetzen, soweit
+ betroffen: Migration, Storage-Vertrag, PostgreSQL-Adapter, API, Client, Web und
+ Tests.
+- Öffentliche Schnittstellen und persistierte Daten nur bewusst und
+ rückwärtskompatibel ändern. Unvermeidbare Brüche klar dokumentieren.
+
+## Struktur und Schichtengrenzen
+
+- `cmd/*` bleibt schlank und enthält nur Konfiguration, Verdrahtung und Startcode.
+- Datenbankzugriffe gehören hinter die Verträge in `internal/storage`; konkrete
+ PostgreSQL-Implementierungen liegen in `internal/storage/postgres`.
+- HTTP- und Berechtigungslogik der JSON-API gehört in `internal/api`.
+- Wiederverwendbare API-Aufrufe des Web-Clients gehören in `lib/client`.
+- `internal/web` greift nicht direkt auf PostgreSQL oder Storage-Modelle zu.
+- Browserpfade werden zentral in `internal/web/paths.go` gepflegt; keine
+ verstreuten Pfad-Literale einführen.
+- Templates, statische Dateien und Mailvorlagen in ihren bestehenden Verzeichnissen
+ ablegen und die vorhandenen Einbettungsmechanismen beibehalten.
+- Neue Pakete nur bilden, wenn sie eine klare Verantwortung besitzen. Keine
+ Sammelpakete wie `utils`, `common` oder `helpers` neu einführen.
+
+## Idiomatischer Go-Code
+
+- Die in `go.mod` festgelegte Go-Version und Standardbibliothek bevorzugen.
+- Geänderte Go-Dateien mit `gofmt` formatieren. Code muss `go vet` und
+ `staticcheck` bestehen.
+- Kleine, fokussierte Funktionen, frühe Rückgaben und verständliche Namen
+ bevorzugen. Kommentare erklären das Warum, nicht den offensichtlichen Ablauf.
+- Fehler mit hilfreichem Kontext und `%w` weiterreichen, wenn Aufrufer die Ursache
+ noch untersuchen sollen. Erwartbare Domänenfehler mit `errors.Is`/`errors.As`
+ behandeln.
+- `context.Context` entlang bestehender Request- und Storage-Grenzen weitergeben;
+ keine neuen Hintergrundkontexte mitten in einem Request-Ablauf erzeugen.
+- Abhängigkeiten explizit verdrahten. Globale veränderliche Zustände vermeiden.
+- Neue Produktionsabhängigkeiten nur bei klarem Mehrwert einführen. Vorhandene
+ Standardbibliotheks- oder Projektlösungen bevorzugen und neue Abhängigkeiten in
+ der Übergabe begründen.
+
+## Keine Codeduplizierung
+
+- Vor dem Anlegen neuer Typen, Validatoren, Abfragen, Handler-Helfer, Clientmethoden
+ oder Template-Teile nach gleichartigem Code suchen.
+- Gemeinsame fachliche Regeln an einer Stelle implementieren und von den
+ aufrufenden Schichten wiederverwenden. API und Web dürfen dieselbe Fachregel
+ nicht unabhängig voneinander nachbilden.
+- Wiederholte SQL-Fragmente, Filter- und Paginglogik über die bereits vorhandenen
+ Storage-Helfer konsolidieren, sofern Semantik und Sicherheitsgrenzen identisch
+ sind.
+- Ähnliche, aber fachlich unterschiedliche Abläufe nicht vorschnell abstrahieren.
+ Eine Abstraktion muss Namen, Verantwortung und Fehlerverhalten klarer machen.
+- Beim Entfernen einer Duplikation alle Aufrufer migrieren und das alte Konstrukt
+ löschen, sobald es nicht mehr benötigt wird.
+
+## Fachliche und sicherheitsrelevante Regeln
+
+- Ein Garten ist eine abgeschlossene Daten- und Berechtigungsgrenze. Jede
+ gartenbezogene Abfrage muss die Garten-ID berücksichtigen.
+- Fremde oder nicht sichtbare Garten-IDs liefern `404`; verbotene Aktionen in einem
+ sichtbaren Garten liefern `403`.
+- Authentifizierung und Autorisierung verbleiben in der API. Das Web leitet das
+ Session-Cookie über einen request-spezifischen API-Client weiter.
+- Rollen und Berechtigungen nicht durch reine UI-Prüfungen absichern.
+- Artenstammdaten und konkrete Pflanzen getrennt halten; Pflanzen dürfen ohne Art
+ existieren.
+- Automatisch erzeugte Aufgaben müssen idempotent bleiben. Fälligkeitsfenster und
+ Zeiträume über Jahresgrenzen hinweg korrekt behandeln.
+- Nutzereingaben an der zuständigen Systemgrenze validieren. HTML-Ausgabe über
+ `html/template` escapen und bestehende CSRF-, Cookie- und Security-Header-
+ Mechanismen nicht umgehen.
+- Keine Zugangsdaten, Tokens, echte personenbezogene Daten oder lokale `.env`- und
+ `.envrc`-Inhalte committen oder in Logs und Tests ausgeben.
+
+## Datenbank und Migrationen
+
+- Schemaänderungen ausschließlich als neues, fortlaufend nummeriertes Paar aus
+ `.up.sql` und `.down.sql` in `internal/storage/postgres/migrations` hinzufügen.
+- Bereits eingecheckte Migrationen nicht nachträglich ändern, außer der Benutzer
+ fordert dies ausdrücklich und die Migration wurde nachweislich noch nirgends
+ angewendet.
+- Migrationen müssen auf einer leeren Datenbank vorwärts laufen. Die Down-Migration
+ muss die Änderung soweit sinnvoll und sicher rückgängig machen.
+- SQL parametrieren, Transaktionsgrenzen bewusst wählen und konkurrierende
+ Zugriffe berücksichtigen.
+- Neue oder geänderte Abfragen durch PostgreSQL-Integrationstests abdecken, wenn
+ Verhalten nicht sinnvoll mit einem Unit-Test geprüft werden kann.
+
+## Tests
+
+- Jede Verhaltensänderung erhält passende Tests, sofern technisch möglich. Fehler-
+ und Berechtigungspfade gehören ebenso dazu wie der Erfolgsfall.
+- Fehlerbehebungen möglichst zuerst mit einem Regressionstest reproduzieren.
+- Tests nahe am getesteten Paket ablegen und vorhandene Testhelfer wiederverwenden.
+ Tabellengetriebene Tests nutzen, wenn mehrere gleichartige Fälle dadurch klarer
+ werden.
+- Tests müssen deterministisch und voneinander unabhängig sein. Keine echten
+ Netzwerkdienste, Uhrzeit oder Zufallswerte unkontrolliert voraussetzen.
+- Bestehende Tests nicht löschen, abschwächen oder überspringen, nur um einen Build
+ grün zu bekommen.
+- Zuerst die direkt betroffenen Pakete testen, anschließend standardmäßig:
+
+ ```sh
+ go test ./...
+ ```
+
+- Vor Abschluss einer größeren oder sicherheitsrelevanten Änderung zusätzlich
+ ausführen:
+
+ ```sh
+ make audit
+ ```
+
+- PostgreSQL-Integrationstests nur mit einer ausdrücklich dafür vorgesehenen
+ Datenbank ausführen:
+
+ ```sh
+ GARDOMATIC_TEST_DB_DSN="$TEST_DATABASE_URL" go test -count=1 ./internal/storage/postgres
+ ```
+
+ `make test/integration` migriert die in `GARDOMATIC_DB_DSN` konfigurierte
+ Datenbank. Niemals versehentlich gegen Produktion ausführen.
+
+## Dokumentation und Abschluss
+
+- README, Konfigurationsbeispiele und weiterführende Dokumentation aktualisieren,
+ wenn sich Setup, Befehle, Umgebungsvariablen oder Nutzerverhalten ändern.
+- Für Beiträge Dritter gelten `CONTRIBUTING.md` und `CLA.md`. Die CLA-Checkbox oder
+ Zustimmung niemals stellvertretend für eine beitragende Person setzen.
+- Den CLA-Workflow nicht umgehen, abschwächen oder auf einen grünen Status setzen,
+ wenn die nachweisbare Zustimmung der beitragenden Person fehlt.
+- Kein Fremdmaterial übernehmen, dessen Herkunft, Lizenz oder Vereinbarkeit mit
+ der Projektlizenz und der CLA unklar ist.
+- Rechtstexte (`LICENSE` und `CLA.md`) nur auf ausdrücklichen Auftrag ändern.
+- Vor der Übergabe den Diff auf unnötige Änderungen, Duplikationen, Geheimnisse und
+ fehlende Tests prüfen.
+- In der Übergabe geänderte Bereiche, ausgeführte Prüfungen und verbleibende Risiken
+ knapp nennen. Übersprungene Prüfungen mit Grund aufführen.
diff --git a/CLA.md b/CLA.md
new file mode 100644
index 0000000..93f0faf
--- /dev/null
+++ b/CLA.md
@@ -0,0 +1,182 @@
+# Gardomatic Contributor License Agreement
+
+Version 1.0, 10 September 2026
+
+Thank you for your interest in contributing to Gardomatic. This Contributor
+License Agreement (the "Agreement") documents the rights granted by contributors
+to Alexander Klein ("We", "Us", or the "Project Owner"). It is based on the
+Harmony Contributor License Agreement Template, Version 1.0.
+
+This is a legally binding agreement. Please read it carefully before submitting a
+Contribution.
+
+## How to accept this Agreement
+
+For an individual Contribution, You accept this Agreement by submitting a pull
+request and checking the CLA acceptance box included in the pull request template.
+The pull request and its history provide the electronic record of acceptance.
+
+If You contribute on behalf of a Legal Entity, or if Your employer owns or may own
+rights in the Contribution, an authorized representative must contact
+alex@kleiax.de before the Contribution can be accepted. We may request a separately
+signed entity agreement.
+
+Do not include a home address, identification document, or other unnecessary
+personal information in a public pull request.
+
+## 1. Definitions
+
+"You" means the individual who Submits a Contribution to Us. When a Contribution
+is made on behalf of a Legal Entity, "You" also means that Legal Entity and its
+Affiliates where the context requires it.
+
+"Legal Entity" means an entity that is not a natural person. "Affiliates" means
+other Legal Entities that control, are controlled by, or are under common control
+with that Legal Entity. "Control" means (i) the direct or indirect power to direct
+the management of an entity, whether by contract or otherwise, (ii) ownership of
+fifty percent or more of the securities entitled to elect its management, or (iii)
+beneficial ownership of the entity.
+
+"Contribution" means any work of authorship that is Submitted by You to Us and in
+which You own or assert ownership of the Copyright.
+
+"Copyright" means all rights protecting works of authorship owned or controlled by
+You or Your Affiliates, including copyright, moral, and neighboring rights, for
+their full term and any extensions.
+
+"Material" means Gardomatic and its associated source code, documentation, media,
+and other materials made available by Us to third parties. After You Submit a
+Contribution, it may be included in the Material.
+
+"Submit" means any electronic, verbal, or written communication sent to Us or our
+representatives through systems managed by or on behalf of Us for discussing or
+improving the Material. This includes pull requests, commits, patches, issue
+trackers, and code review comments. Communications conspicuously marked "Not a
+Contribution" are excluded.
+
+"Submission Date" means the date on which You Submit a Contribution to Us.
+
+"Effective Date" means the date You accept this Agreement or the date You first
+Submit a Contribution to Us, whichever is earlier.
+
+## 2. Grant of Rights
+
+### 2.1 Copyright License
+
+You retain ownership of the Copyright in Your Contribution and keep the same rights
+to use or license the Contribution that You would have had without entering into
+this Agreement.
+
+To the maximum extent permitted by applicable law, You grant to Us a perpetual,
+worldwide, non-exclusive, transferable, royalty-free, and irrevocable license under
+the Copyright covering the Contribution. This license includes the right to
+sublicense through multiple tiers of sublicensees and to reproduce, modify,
+prepare derivative works of, display, perform, distribute, and otherwise use the
+Contribution as part of the Material, subject to Section 2.3.
+
+### 2.2 Patent License
+
+For patent claims, including method, process, and apparatus claims, that You or Your
+Affiliates own, control, or have the right to grant now or in the future, You grant
+to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, and
+irrevocable patent license, with the right to sublicense through multiple tiers of
+sublicensees, to make, have made, use, sell, offer for sale, import, and otherwise
+transfer the Contribution and the Contribution in combination with the Material.
+
+This patent license is granted only to the extent that exercising the rights in the
+Contribution would infringe those patent claims and is subject to Section 2.3.
+
+### 2.3 Outbound License
+
+If We include Your Contribution in the Material, We may license the Contribution
+under any license, including copyleft, permissive, commercial, or proprietary
+licenses.
+
+As a condition of exercising this right, We will also make the Contribution
+available under the license or licenses that apply to the Material on the
+Submission Date. At the time this Agreement was published, that license is the
+PolyForm Noncommercial License 1.0.0.
+
+### 2.4 Moral Rights
+
+If moral rights apply to the Contribution, to the maximum extent permitted by law,
+You waive and agree not to assert those rights against Us, our successors in
+interest, or our direct or indirect licensees in connection with exercising the
+rights granted by this Agreement.
+
+### 2.5 Our Rights
+
+We are not obligated to use, merge, publish, or continue to distribute any
+Contribution.
+
+### 2.6 Reservation of Rights
+
+Any rights not expressly licensed under this Section 2 are reserved by You.
+
+## 3. Your Confirmations
+
+You confirm that:
+
+1. You have the legal authority to enter into this Agreement.
+2. You or Your Affiliates own the Copyright and patent claims covering the
+ Contribution that are required to grant the rights in Section 2.
+3. The rights granted under Section 2 do not violate rights previously granted to
+ third parties, including Your employer.
+4. If You are an employee and Your employer owns or may own rights in the
+ Contribution, You have obtained its written approval or an authorized
+ representative has entered into an entity agreement with Us.
+5. You have clearly identified any part of the Contribution that You did not author
+ and have provided its source and applicable license. You will not Submit
+ third-party material unless its license permits the Contribution and the rights
+ granted by this Agreement.
+6. If You are under eighteen years old, Your parent or legal guardian has approved
+ Your acceptance of this Agreement.
+
+## 4. Disclaimer
+
+EXCEPT FOR THE EXPRESS CONFIRMATIONS IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS
+IS". TO THE MAXIMUM EXTENT PERMITTED BY LAW, YOU DISCLAIM ALL EXPRESS OR IMPLIED
+WARRANTIES, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+PURPOSE, AND NON-INFRINGEMENT. IF A WARRANTY CANNOT BE DISCLAIMED, IT IS LIMITED TO
+THE MINIMUM SCOPE AND DURATION PERMITTED BY LAW.
+
+## 5. Consequential Damage Waiver
+
+TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NEITHER YOU NOR WE WILL BE LIABLE
+TO THE OTHER UNDER THIS AGREEMENT FOR LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS,
+LOSS OF DATA, OR INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR EXEMPLARY
+DAMAGES, REGARDLESS OF THE LEGAL THEORY ON WHICH THE CLAIM IS BASED.
+
+## 6. Miscellaneous
+
+1. This Agreement is governed by the laws of the Federal Republic of Germany,
+ excluding its conflict-of-law rules and the United Nations Convention on
+ Contracts for the International Sale of Goods.
+2. This Agreement is the entire agreement between You and Us concerning Your
+ Contributions and replaces prior agreements or understandings about those
+ Contributions.
+3. If You or We transfer rights or obligations received under this Agreement to a
+ third party, that third party must agree in writing to comply with the relevant
+ rights and obligations of this Agreement.
+4. A failure to require performance of a provision once does not waive the right to
+ require it later.
+5. If a provision is found invalid or unenforceable, it will be replaced to the
+ extent possible by an enforceable provision that most closely reflects its
+ purpose. The remaining provisions continue in effect.
+6. We may publish a new version of this Agreement for future Contributions. A new
+ version does not retroactively change the terms governing Contributions already
+ Submitted under an earlier version.
+
+## Template attribution
+
+This Agreement is adapted from the Harmony Contributor Agreement Template,
+Version 1.0, published by Harmony Agreements under the Creative Commons Attribution
+3.0 Unported License:
+
+
+
+The template has been customized for Gardomatic by selecting a contributor license
+(not a copyright assignment), selecting Harmony outbound license Option Five,
+naming the parties and project, defining electronic acceptance, choosing German
+law, and removing unused template alternatives and placeholders. Harmony
+Agreements does not endorse this adaptation.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2bd6762
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,79 @@
+# Zu Gardomatic beitragen
+
+Vielen Dank für dein Interesse an Gardomatic. Beiträge sollen klein,
+nachvollziehbar und mit der bestehenden Architektur vereinbar sein.
+
+## Vor dem ersten Beitrag
+
+1. Lies die Projektbeschreibung und den Entwicklungsablauf in der
+ [`README.md`](README.md).
+2. Beachte die verbindlichen technischen Regeln in [`AGENTS.md`](AGENTS.md).
+3. Lies die [`Contributor License Agreement`](CLA.md). Sie ist eine rechtlich
+ bindende Vereinbarung.
+
+Mit der CLA behältst du das Copyright an deinem Beitrag. Du erlaubst Alexander
+Klein zugleich, den Beitrag innerhalb von Gardomatic unter der öffentlichen
+PolyForm-Lizenz sowie unter separaten kommerziellen oder proprietären Lizenzen zu
+verwenden. Das ermöglicht das in diesem Projekt vorgesehene Dual-Licensing-Modell.
+
+### CLA annehmen
+
+Einzelpersonen nehmen Version 1.0 der CLA an, indem sie einen Pull Request mit der
+unveränderten CLA-Erklärung aus der Pull-Request-Vorlage einreichen und die
+zugehörige Checkbox selbst markieren. Die Annahme gilt auch für spätere Beiträge
+unter derselben CLA-Version.
+
+Der Workflow `.github/workflows/cla.yml` prüft diese Erklärung bei jedem Pull
+Request. Ein fehlgeschlagener CLA-Check darf nicht umgangen oder administrativ als
+Ersatz für die Zustimmung der beitragenden Person bestätigt werden.
+
+Beiträge im Namen eines Unternehmens oder einer anderen Organisation müssen vorab
+mit `alex@kleiax.de` abgestimmt werden. Dasselbe gilt, wenn dein Arbeitgeber Rechte
+an deinem Beitrag besitzen könnte. Eine Projektperson darf die CLA-Erklärung nicht
+stellvertretend für Beitragende markieren.
+
+## Entwicklungsablauf
+
+1. Erstelle einen fokussierten Branch und beschreibe das zu lösende Problem.
+2. Suche vor der Implementierung nach vorhandenen Helfern und ähnlichen Abläufen.
+3. Halte die Schichtengrenzen zwischen API, Storage, Client und Web ein.
+4. Ergänze Tests für neues oder korrigiertes Verhalten, soweit technisch möglich.
+5. Formatiere geänderten Go-Code mit `gofmt`.
+6. Führe zunächst die betroffenen Tests und anschließend `go test ./...` aus.
+7. Führe bei größeren Änderungen zusätzlich `make audit` aus.
+8. Erläutere im Pull Request Verhalten, Motivation, Tests und mögliche Risiken.
+
+Für Datenbankänderungen ist ein neues Up-/Down-Migrationspaar erforderlich.
+Bereits veröffentlichte Migrationen dürfen nicht nachträglich verändert werden.
+
+## Pull Requests
+
+Ein Pull Request sollte:
+
+- genau ein zusammenhängendes Problem lösen,
+- keine unbeabsichtigten oder fachfremden Änderungen enthalten,
+- vorhandene Tests nicht abschwächen,
+- geändertes Nutzerverhalten und Konfiguration dokumentieren,
+- alle verwendeten Quellen und Fremdmaterialien mit ihrer Lizenz offenlegen und
+- die CLA-Erklärung enthalten.
+
+Ein Beitrag kann abgelehnt oder bis zur Klärung zurückgestellt werden. Das gilt
+insbesondere bei fehlender CLA-Annahme, unklaren Rechten, Sicherheitsproblemen,
+fehlenden Tests oder Änderungen außerhalb des vereinbarten Umfangs.
+
+## Fremdmaterial und KI-gestützte Beiträge
+
+Reiche nur Material ein, an dem du die erforderlichen Rechte besitzt. Kopiere
+keinen Code, keine Texte, Bilder oder anderen Inhalte aus inkompatibel lizenzierten
+Quellen. Kennzeichne Fremdmaterial und nenne Quelle sowie Lizenz.
+
+Für KI-gestützte Beiträge bleibst du selbst verantwortlich. Prüfe den erzeugten
+Inhalt auf Korrektheit, Sicherheit, Herkunft und mögliche Lizenzkonflikte, bevor du
+ihn einreichst.
+
+## Sicherheitsprobleme
+
+Noch nicht veröffentlichte Sicherheitslücken sollten nicht als öffentlicher Issue
+oder Pull Request eingereicht werden. Melde sie zunächst vertraulich an
+`alex@kleiax.de` und füge keine echten Zugangsdaten oder personenbezogenen Daten
+bei.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9398062
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+FROM docker.io/library/golang:1.26-alpine AS build
+
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+FROM build AS api-build
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
+
+FROM build AS web-build
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/web ./cmd/web
+
+FROM docker.io/library/alpine:3.22 AS runtime
+
+RUN apk add --no-cache ca-certificates \
+ && addgroup -S gardomatic \
+ && adduser -S -G gardomatic gardomatic
+
+WORKDIR /app
+USER gardomatic
+
+FROM runtime AS api
+COPY --from=api-build /out/api /app/api
+EXPOSE 4000
+ENTRYPOINT ["/app/api"]
+
+FROM runtime AS web
+COPY --from=web-build /out/web /app/web
+EXPOSE 4040
+ENTRYPOINT ["/app/web"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a8e6626
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,136 @@
+# PolyForm Noncommercial License 1.0.0
+
+
+
+## Acceptance
+
+In order to get any license under these terms, you must agree
+to them as both strict obligations and conditions to all
+your licenses.
+
+## Copyright License
+
+The licensor grants you a copyright license for the
+software to do everything you might do with the software
+that would otherwise infringe the licensor's copyright
+in it for any permitted purpose. However, you may
+only distribute the software according to [Distribution
+License](#distribution-license) and make changes or new works
+based on the software according to [Changes and New Works
+License](#changes-and-new-works-license).
+
+## Distribution License
+
+The licensor grants you an additional copyright license
+to distribute copies of the software. Your license
+to distribute covers distributing the software with
+changes and new works permitted by [Changes and New Works
+License](#changes-and-new-works-license).
+
+## Notices
+
+You must ensure that anyone who gets a copy of any part of
+the software from you also gets a copy of these terms or the
+URL for them above, as well as copies of any plain-text lines
+beginning with `Required Notice:` that the licensor provided
+with the software. For example:
+
+> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
+
+## Changes and New Works License
+
+The licensor grants you an additional copyright license to
+make changes and new works based on the software for any
+permitted purpose.
+
+## Patent License
+
+The licensor grants you a patent license for the software that
+covers patent claims the licensor can license, or becomes able
+to license, that you would infringe by using the software.
+
+## Noncommercial Purposes
+
+Any noncommercial purpose is a permitted purpose.
+
+## Personal Uses
+
+Personal use for research, experiment, and testing for
+the benefit of public knowledge, personal study, private
+entertainment, hobby projects, amateur pursuits, or religious
+observance, without any anticipated commercial application,
+is use for a permitted purpose.
+
+## Noncommercial Organizations
+
+Use by any charitable organization, educational institution,
+public research organization, public safety or health
+organization, environmental protection organization,
+or government institution is use for a permitted purpose
+regardless of the source of funding or obligations resulting
+from the funding.
+
+## Fair Use
+
+You may have "fair use" rights for the software under the
+law. These terms do not limit them.
+
+## No Other Rights
+
+These terms do not allow you to sublicense or transfer any of
+your licenses to anyone else, or prevent the licensor from
+granting licenses to anyone else. These terms do not imply
+any other licenses.
+
+## Patent Defense
+
+If you make any written claim that the software infringes or
+contributes to infringement of any patent, your patent license
+for the software granted under these terms ends immediately. If
+your company makes such a claim, your patent license ends
+immediately for work on behalf of your company.
+
+## Violations
+
+The first time you are notified in writing that you have
+violated any of these terms, or done anything with the software
+not covered by your licenses, your licenses can nonetheless
+continue if you come into full compliance with these terms,
+and take practical steps to correct past violations, within
+32 days of receiving notice. Otherwise, all your licenses
+end immediately.
+
+## No Liability
+
+***As far as the law allows, the software comes as is, without
+any warranty or condition, and the licensor will not be liable
+to you for any damages arising out of these terms or the use
+or nature of the software, under any kind of legal claim.***
+
+## Definitions
+
+The **licensor** is the individual or entity offering these
+terms, and the **software** is the software the licensor makes
+available under these terms.
+
+**You** refers to the individual or entity agreeing to these
+terms.
+
+**Your company** is any legal entity, sole proprietorship,
+or other kind of organization that you work for, plus all
+organizations that have control over, are under the control of,
+or are under common control with that organization. **Control**
+means ownership of substantially all the assets of an entity,
+or the power to direct its management and policies by vote,
+contract, or otherwise. Control can be direct or indirect.
+
+**Your licenses** are all the licenses granted to you for the
+software under these terms.
+
+**Use** means anything you do with the software requiring one
+of your licenses.
+
+Required Notice: Copyright © 2026 Alexander Klein.
+
+For permission to use Gardomatic commercially, contact
+Alexander Klein at alex@kleiax.de to obtain a separate commercial license.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3e95c09
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,197 @@
+SHELL := /bin/bash
+.SHELLFLAGS := -eu -o pipefail -c
+.DEFAULT_GOAL := help
+.DELETE_ON_ERROR:
+
+# Non-secret deployment settings. Runtime secrets remain in the shell-compatible
+# .envrc and remote/setup/.env files instead of being parsed by Make.
+-include config.mk
+
+define load_envrc
+set -a; [[ ! -f ./.envrc ]] || source ./.envrc; set +a;
+endef
+
+# ==================================================================================== #
+# HELPERS
+# ==================================================================================== #
+
+## help: print this help message
+.PHONY: help
+help:
+ @echo 'Usage:'
+ @sed -n 's/^##//p' $(firstword $(MAKEFILE_LIST)) | column -t -s ':' | sed -e 's/^/ /'
+
+.PHONY: confirm
+confirm:
+ @read -r -p "Are you sure? [y/N] " answer && [[ "$${answer:-N}" == y ]]
+
+## config/init: create missing local configuration files from their examples
+.PHONY: config/init
+config/init:
+ @if [[ -e ./.env ]]; then echo 'keep .env'; else install -m 0600 ./.env.example ./.env; echo 'create .env'; fi
+ @if [[ -e ./.envrc ]]; then echo 'keep .envrc'; else install -m 0600 ./.envrc.example ./.envrc; echo 'create .envrc'; fi
+ @if [[ -e ./config.mk ]]; then echo 'keep config.mk'; else install -m 0600 ./config.mk.example ./config.mk; echo 'create config.mk'; fi
+ @if [[ -e ./remote/setup/.env ]]; then echo 'keep remote/setup/.env'; else install -m 0600 ./remote/setup/.env.example ./remote/setup/.env; echo 'create remote/setup/.env'; fi
+
+# ==================================================================================== #
+# DEVELOPMENT
+# ==================================================================================== #
+
+## run/api: run the cmd/api application
+.PHONY: run/api
+run/api:
+ @${load_envrc} go run ./cmd/api
+
+## run/web: run the cmd/web application
+.PHONY: run/web
+run/web:
+ @${load_envrc} go run ./cmd/web
+
+## run/cli: run the cmd/cli administration tool (use ARGS='...')
+.PHONY: run/cli
+run/cli:
+ @${load_envrc} go run ./cmd/cli ${ARGS}
+
+## db/psql: connect to the database using psql
+.PHONY: db/psql
+db/psql:
+ @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; psql "$$GARDOMATIC_DB_DSN"
+
+## db/migrations/new name=$1: create a new database migration
+.PHONY: db/migrations/new
+db/migrations/new:
+ @[[ "${name}" =~ ^[a-z0-9_]+$$ ]] || { echo 'name must contain lowercase letters, digits, or underscores' >&2; exit 1; }
+ migrate create -seq -ext=.sql -dir=./internal/storage/postgres/migrations "${name}"
+
+## db/migrations/up: apply all up database migrations
+.PHONY: db/migrations/up
+db/migrations/up:
+ @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" up
+
+## db/migrations/down: apply all down database migrations
+.PHONY: db/migrations/down
+db/migrations/down: confirm
+ @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" down
+
+# ==================================================================================== #
+# QUALITY CONTROL
+# ==================================================================================== #
+
+## tidy: tidy module dependencies and format all Go files
+.PHONY: tidy
+tidy:
+ go mod tidy
+ go mod verify
+ go fmt ./...
+
+## audit: run quality control checks
+.PHONY: audit
+audit:
+ go mod tidy -diff
+ go mod verify
+ go vet ./...
+ go tool staticcheck ./...
+ go test -race -vet=off ./...
+
+## test/integration: migrate the configured PostgreSQL database and run integration tests
+.PHONY: test/integration
+test/integration:
+ @${load_envrc} test -n "$${GARDOMATIC_TEST_DB_DSN:-}" || { echo 'GARDOMATIC_TEST_DB_DSN is required and must point to a disposable test database' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_TEST_DB_DSN" up; go test -count=1 ./internal/storage/postgres
+
+# ==================================================================================== #
+# BUILD
+# ==================================================================================== #
+
+.PHONY: build/dirs
+build/dirs:
+ mkdir -p ./bin ${PRODUCTION_BUILD_DIR}
+
+## build/api: build the cmd/api application
+.PHONY: build/api
+build/api: build/dirs
+ go build -ldflags='-s' -o=./bin/api ./cmd/api
+
+## build/web: build the cmd/web application
+.PHONY: build/web
+build/web: build/dirs
+ go build -ldflags='-s' -o=./bin/web ./cmd/web
+
+## build/cli: build the cmd/cli administration tool
+.PHONY: build/cli
+build/cli: build/dirs
+ go build -ldflags='-s' -o=./bin/cli ./cmd/cli
+
+## build/production: cross-compile all production binaries
+.PHONY: build/production
+build/production: build/dirs
+ GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/api ./cmd/api
+ GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/web ./cmd/web
+ GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/cli ./cmd/cli
+
+## code/stats: print source lines and their estimated number of book pages
+.PHONY: code/stats
+code/stats:
+ @lines=$$(git ls-files -z -- '*.go' '*.tmpl' '*.html' '*.css' '*.js' '*.sql' '*.sh' \
+ ':(exclude)vendor/**' ':(exclude)**/*.min.css' ':(exclude)**/*.min.js' \
+ | xargs -0 awk 'END { print NR }'); \
+ pages=$$((($$lines + 49) / 50)); \
+ printf 'Codeumfang: %s Zeilen\nBuchumfang: ca. %s Seiten (bei 50 Codezeilen pro Seite)\n' "$$lines" "$$pages"
+
+## build/all: build API, web, and administration CLI, then print code statistics
+.PHONY: build/all
+build/all: build/api build/web build/cli
+ @$(MAKE) --no-print-directory code/stats
+
+# ==================================================================================== #
+# PRODUCTION
+# ==================================================================================== #
+
+PRODUCTION_HOST ?=
+PRODUCTION_SSH_USER ?= root
+PRODUCTION_SSH_PORT ?= 22
+PRODUCTION_SSH_IDENTITY_FILE ?=
+PRODUCTION_GOOS ?= linux
+PRODUCTION_GOARCH ?= amd64
+PRODUCTION_BUILD_DIR = ./bin/${PRODUCTION_GOOS}_${PRODUCTION_GOARCH}
+
+production_target = ${PRODUCTION_SSH_USER}@${PRODUCTION_HOST}
+production_ssh_options = -p ${PRODUCTION_SSH_PORT}
+ifneq ($(strip ${PRODUCTION_SSH_IDENTITY_FILE}),)
+production_ssh_options += -i ${PRODUCTION_SSH_IDENTITY_FILE}
+endif
+
+.PHONY: production/check-config
+production/check-config:
+ @test -n "${PRODUCTION_HOST}" || { echo 'PRODUCTION_HOST is required; configure it in config.mk' >&2; exit 1; }
+ @test -n "${PRODUCTION_SSH_USER}" || { echo 'PRODUCTION_SSH_USER is required' >&2; exit 1; }
+ @[[ "${PRODUCTION_SSH_PORT}" =~ ^[0-9]+$$ ]] && (( ${PRODUCTION_SSH_PORT} >= 1 && ${PRODUCTION_SSH_PORT} <= 65535 )) || { echo 'PRODUCTION_SSH_PORT must be between 1 and 65535' >&2; exit 1; }
+ @[[ "${PRODUCTION_GOOS}" == linux ]] || { echo 'PRODUCTION_GOOS must be linux' >&2; exit 1; }
+ @[[ "${PRODUCTION_GOARCH}" == amd64 || "${PRODUCTION_GOARCH}" == arm64 ]] || { echo 'PRODUCTION_GOARCH must be amd64 or arm64' >&2; exit 1; }
+ @if [[ -n "${PRODUCTION_SSH_IDENTITY_FILE}" && ! -f "${PRODUCTION_SSH_IDENTITY_FILE}" ]]; then echo 'PRODUCTION_SSH_IDENTITY_FILE does not exist' >&2; exit 1; fi
+
+## production/connect: connect to the production server
+.PHONY: production/connect
+production/connect: production/check-config
+ ssh ${production_ssh_options} ${production_target}
+
+## production/provision: provision a fresh Ubuntu server (root or passwordless sudo required)
+.PHONY: production/provision
+production/provision: production/check-config
+ @test -f ./remote/setup/.env || { echo 'Copy remote/setup/.env.example to remote/setup/.env and configure it first' >&2; exit 1; }
+ @permissions=$$(stat -c '%a' ./remote/setup/.env); (( (8#$$permissions & 077) == 0 )) || { echo 'remote/setup/.env must have mode 0600' >&2; exit 1; }
+ rsync -P -e "ssh ${production_ssh_options}" ./remote/setup/provision-server.sh ${production_target}:/tmp/gardomatic-provision-server.sh
+ ssh ${production_ssh_options} ${production_target} 'status=0; chmod 700 /tmp/gardomatic-provision-server.sh && /tmp/gardomatic-provision-server.sh --env-file - || status=$$?; rm -f /tmp/gardomatic-provision-server.sh; exit $$status' < ./remote/setup/.env
+
+## production/deploy: deploy API and web to production
+.PHONY: production/deploy
+production/deploy: production/check-config build/production
+ ssh ${production_ssh_options} ${production_target} 'mkdir -p "$$HOME/gardomatic-deploy/migrations"'
+ rsync -P -e "ssh ${production_ssh_options}" ${PRODUCTION_BUILD_DIR}/api ${PRODUCTION_BUILD_DIR}/web ${PRODUCTION_BUILD_DIR}/cli ${production_target}:gardomatic-deploy/
+ rsync -rP --delete -e "ssh ${production_ssh_options}" ./internal/storage/postgres/migrations/ ${production_target}:gardomatic-deploy/migrations/
+ rsync -P -e "ssh ${production_ssh_options}" ./remote/production/api.service ./remote/production/web.service ./remote/production/deploy-server.sh ./remote/setup/create-admin.sh ${production_target}:gardomatic-deploy/
+ ssh -t ${production_ssh_options} ${production_target} 'chmod 700 "$$HOME/gardomatic-deploy/deploy-server.sh" && "$$HOME/gardomatic-deploy/deploy-server.sh"'
+
+## production/create-admin: interactively create an active application administrator
+.PHONY: production/create-admin
+production/create-admin: production/check-config
+ ssh -t ${production_ssh_options} ${production_target} /usr/local/sbin/gardomatic-create-admin
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0aedef0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,456 @@
+# Gardomatic
+
+Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur gemeinsamen
+Organisation von Gärten. Sie bündelt Pflanzenwissen, Pflanzorte, anstehende
+Arbeiten, Bilder und ein Gartentagebuch in einer Anwendung.
+
+Das Projekt besteht aus einer JSON-API und einer serverseitig gerenderten
+Webanwendung in Go. PostgreSQL speichert alle Fachdaten. Das Frontend ist
+mobile-first, bleibt in den wesentlichen Abläufen ohne JavaScript nutzbar und
+verwendet htmx für komfortable Teilaktualisierungen. Eine installierbare PWA stellt
+eine Offline-App-Shell bereit; die eigentlichen Gartendaten bleiben serverseitig.
+
+> **Projektstatus:** Gardomatic wird aktiv entwickelt. Datenmodell, API und
+> Bedienoberfläche können sich noch verändern.
+
+## Funktionen
+
+- mehrere voneinander isolierte Gärten pro Benutzer
+- gemeinsame Gartenpflege mit Einladungen, Rollen und Berechtigungen
+- Verwaltung von Arten, Sorten, konkreten Pflanzen und Pflanzorten
+- Pflegehinweise, Kategorien, Tags und Bilder
+- Aufgaben mit Fälligkeitsfenstern, Prioritäten und Wiederholungen
+- Aufgabenvorlagen pro Art und automatische, idempotente Aufgabenerzeugung
+- Aufgabenansicht und Kalenderdarstellung
+- Gartentagebuch, Pinnwand und Bildbibliothek
+- Benutzerkonto, Aktivierung, Sitzungsverwaltung und Passwortänderung
+- administrative Einladung, Anonymisierung und Verwaltung von Benutzern, Rollen und Anwendungseinstellungen inklusive maskierter Laufzeitkonfiguration und Testmailversand
+- Kommandozeilenwerkzeug zur Benutzer- und Datenbankadministration
+
+## Architektur
+
+```text
+Browser
+ │
+ ▼
+Webanwendung :4040 ──► typisierter API-Client
+ │
+ ▼
+ JSON-API :4000
+ │
+ ▼
+ Storage-Schnittstellen
+ │
+ ▼
+ PostgreSQL 16
+```
+
+Die API ist die maßgebliche Sicherheits- und Fachgrenze. Sie verwaltet
+Authentifizierung, Sitzungen, Autorisierung, Validierung und Datenzugriff. Die
+Webanwendung greift nicht direkt auf die Datenbank zu, sondern leitet das
+Session-Cookie eines Requests über `lib/client` an die API weiter.
+
+Gartenbezogene Browserrouten beginnen mit `/g/{gardenID}/`, die entsprechenden
+API-Ressourcen mit `/v1/gardens/{gardenID}/`. Ein Garten bildet eine abgeschlossene
+Daten- und Berechtigungsgrenze.
+
+### Programme
+
+| Programm | Aufgabe | Standardadresse |
+| --- | --- | --- |
+| `cmd/api` | JSON-API und Fachlogik | `http://localhost:4000` |
+| `cmd/web` | serverseitig gerenderte Weboberfläche | `http://localhost:4040` |
+| `cmd/cli` | Administration direkt über PostgreSQL | keine |
+
+## Voraussetzungen
+
+Für den vollständigen lokalen Betrieb werden benötigt:
+
+- Go gemäß der Version in `go.mod` (aktuell Go 1.26 oder neuer)
+- PostgreSQL 16
+- optional Docker oder Podman mit Compose-Unterstützung
+- [`golang-migrate`](https://github.com/golang-migrate/migrate) für lokale
+ Migrationen und Integrationstests
+- `make` für die bereitgestellten Entwicklungsbefehle
+
+Die Compose-Variante bringt PostgreSQL und `golang-migrate` bereits als Container
+mit. Go wird dort nur benötigt, wenn das Administrations-CLI auf dem Host verwendet
+werden soll.
+
+## Schnellstart mit Compose
+
+1. Konfiguration anlegen und mindestens das Datenbankpasswort ändern:
+
+ ```sh
+ cp .env.example .env
+ ```
+
+ `POSTGRES_PASSWORD` und das Passwort in `GARDOMATIC_DB_DSN` sollten
+ übereinstimmen, damit auch lokale CLI- und Testbefehle dieselbe Datenbank
+ erreichen können. `.env` ist von Git ausgeschlossen.
+
+2. Datenbank, Migrationen, API und Webanwendung starten:
+
+ ```sh
+ docker compose up --build
+ ```
+
+ Bei Podman kann je nach Installation stattdessen `podman compose` oder
+ `podman-compose` verwendet werden.
+
+3. Einen ersten aktiven Benutzer anlegen. Dazu in einem zweiten Terminal die DSN
+ aus der lokalen `.env` setzen und das CLI starten:
+
+ ```sh
+ export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable'
+ go run ./cmd/cli users create \
+ --name 'Admin' \
+ --email 'admin@example.com' \
+ --role application:admin \
+ --active \
+ --generate-password
+ ```
+
+ Das generierte Passwort wird einmalig ausgegeben.
+
+4. [http://localhost:4040](http://localhost:4040) öffnen und anmelden.
+
+Die API-Gesundheitsprüfung ist unter
+[http://localhost:4000/v1/healthcheck](http://localhost:4000/v1/healthcheck), die
+Web-Gesundheitsprüfung unter [http://localhost:4040/ping](http://localhost:4040/ping)
+erreichbar. Eine lesbare Statusseite mit API- und Serverinformationen steht unter
+[http://localhost:4040/healtcheck](http://localhost:4040/healtcheck) bereit.
+
+Den Stack beendet `docker compose down`. Die PostgreSQL-Daten liegen im benannten
+Volume `gardomatic-postgres-data` und bleiben dabei erhalten. `docker compose down
+-v` löscht dieses Volume und damit die lokale Datenbank dauerhaft.
+
+## Lokale Entwicklung ohne Anwendungscontainer
+
+PostgreSQL und die Migrationen können weiterhin über Compose laufen:
+
+```sh
+cp .env.example .env
+docker compose up -d postgres migrate
+```
+
+Der `Makefile` bindet eine lokale, nicht versionierte `.envrc` ein. Mindestens die
+Datenbankverbindung muss darin für API-, CLI- und Datenbankbefehle exportiert sein:
+
+```sh
+export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable'
+```
+
+Danach API und Webanwendung in getrennten Terminals starten:
+
+```sh
+make run/api
+```
+
+```sh
+make run/web
+```
+
+Alternativ können beide Programme direkt mit `go run ./cmd/api` und
+`go run ./cmd/web` gestartet werden, sofern die benötigten Umgebungsvariablen in
+der Shell gesetzt sind. Die API benötigt zwingend `GARDOMATIC_DB_DSN`; alle
+anderen Entwicklungswerte besitzen sinnvolle Standardwerte.
+
+## Konfiguration
+
+`.env.example` dokumentiert eine vollständige lokale Konfiguration. Geheimnisse
+gehören ausschließlich in `.env`, `.envrc`, einen Secret Store oder die
+Produktionsumgebung und dürfen nicht eingecheckt werden.
+
+### Compose-Variablen
+
+| Variable | Standard/Beispiel | Beschreibung |
+| --- | --- | --- |
+| `POSTGRES_DB` | `gardomatic` | Datenbankname des PostgreSQL-Containers |
+| `POSTGRES_USER` | `gardomatic` | Datenbankbenutzer des Containers |
+| `POSTGRES_PASSWORD` | `change-me` | Datenbankpasswort; lokal unbedingt ändern |
+| `POSTGRES_PORT` | `5432` | auf dem Host veröffentlichter PostgreSQL-Port |
+| `API_PORT` | `4000` | auf dem Host veröffentlichter API-Port |
+| `WEB_PORT` | `4040` | auf dem Host veröffentlichter Web-Port |
+
+### Laufzeitvariablen
+
+| Variable | Standard | Verwendung |
+| --- | --- | --- |
+| `GARDOMATIC_ENV` | `development` | `development`, `test` oder `production` |
+| `GARDOMATIC_DB_DSN` | erforderlich | PostgreSQL-Verbindungszeichenfolge für API und CLI |
+| `GARDOMATIC_DB_MAX_OPEN_CONNS` | `25` | maximale offene DB-Verbindungen |
+| `GARDOMATIC_DB_MAX_IDLE_CONNS` | `25` | maximale ungenutzte DB-Verbindungen |
+| `GARDOMATIC_DB_MAX_IDLE_TIME` | `15m` | maximale Leerlaufzeit einer DB-Verbindung |
+| `GARDOMATIC_API_HOST` | leer | Bind-Adresse der API |
+| `GARDOMATIC_API_PORT` | `4000` | Listener-Port der API |
+| `GARDOMATIC_WEB_HOST` | leer | Bind-Adresse der Webanwendung |
+| `GARDOMATIC_WEB_PORT` | `4040` | Listener-Port der Webanwendung |
+| `GARDOMATIC_API_BASE_URL` | `http://localhost:4000` | API-Adresse für den Web-Client |
+| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | öffentliche Basis-URL für Links aus API und CLI |
+| `GARDOMATIC_SESSION_COOKIE_NAME` | `gardomatic_session` | gemeinsamer Name des Session-Cookies |
+| `GARDOMATIC_SESSION_LIFETIME` | `12h` | absolute Lebensdauer einer Sitzung |
+| `GARDOMATIC_SESSION_IDLE_TIMEOUT` | `30m` | Ablaufzeit bei Inaktivität |
+| `GARDOMATIC_COOKIE_SECURE` | `false` | nur HTTPS-Cookies; in Produktion zwingend `true` |
+| `GARDOMATIC_RATE_LIMIT_ENABLED` | `true` | API-Ratenbegrenzung aktivieren |
+| `GARDOMATIC_RATE_LIMIT_RPS` | `10` | erlaubte Requests pro Sekunde |
+| `GARDOMATIC_RATE_LIMIT_BURST` | `40` | kurzfristig erlaubte Request-Spitze |
+| `GARDOMATIC_CORS_TRUSTED_ORIGINS` | leer/lokal gesetzt | kommaseparierte erlaubte Origins |
+
+### E-Mail-Versand
+
+| Variable | Standard | Beschreibung |
+| --- | --- | --- |
+| `GARDOMATIC_SMTP_MODE` | `file` | `file` für Entwicklung oder `smtp` |
+| `GARDOMATIC_SMTP_HOST` | leer | SMTP-Server |
+| `GARDOMATIC_SMTP_PORT` | `25` | SMTP-Port |
+| `GARDOMATIC_SMTP_USERNAME` | leer | SMTP-Benutzername |
+| `GARDOMATIC_SMTP_PASSWORD` | leer | SMTP-Passwort |
+| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Absenderadresse |
+| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Ausgabe im `file`-Modus |
+
+Im Entwicklungsmodus schreibt der voreingestellte `file`-Mailer E-Mails in die
+angegebene Datei. In der Compose-API liegt diese Datei innerhalb des Containers;
+sie kann beispielsweise mit `docker compose exec api cat
+/tmp/gardomatic-mails.log` gelesen werden.
+
+## Datenbankmigrationen
+
+Migrationen liegen als fortlaufend nummerierte Up-/Down-Paare in
+`internal/storage/postgres/migrations`.
+
+Eine neue Migration anlegen:
+
+```sh
+make db/migrations/new name=describe_change
+```
+
+Alle ausstehenden Migrationen anwenden:
+
+```sh
+make db/migrations/up
+```
+
+Alle angewendeten Migrationen zurücknehmen:
+
+```sh
+make db/migrations/down
+```
+
+`migrate down` ohne Schrittzahl setzt das gesamte Schema zurück und kann sämtliche
+Anwendungsdaten löschen. Die Make-Ziele verwenden `GARDOMATIC_DB_DSN`; vor Up- und
+besonders Down-Befehlen daher immer prüfen, auf welche Datenbank die Variable zeigt.
+Bereits veröffentlichte Migrationen sollten nicht verändert werden;
+Schemaänderungen erhalten eine neue Migration.
+
+## Tests und Qualitätsprüfungen
+
+Alle Unit- und Handler-Tests ausführen:
+
+```sh
+go test ./...
+```
+
+Die PostgreSQL-Integrationstests werden ohne `GARDOMATIC_TEST_DB_DSN` automatisch
+übersprungen. Für einen vollständigen Integrationslauf eine separate Testdatenbank
+konfigurieren und zuerst migrieren:
+
+```sh
+export GARDOMATIC_TEST_DB_DSN='postgres://user:password@localhost/gardomatic_test?sslmode=disable'
+migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_TEST_DB_DSN" up
+go test -count=1 ./internal/storage/postgres
+```
+
+Alternativ migriert folgendes Ziel die in `GARDOMATIC_DB_DSN` konfigurierte
+Datenbank und führt die Integrationstests dagegen aus:
+
+```sh
+make test/integration
+```
+
+Dieses Ziel ausschließlich mit einer entbehrlichen Testdatenbank verwenden.
+
+Der vollständige lokale Qualitätslauf umfasst Modulprüfung, `go vet`,
+`staticcheck` und Tests mit Race Detector:
+
+```sh
+make audit
+```
+
+Quelltext modernisieren und formatieren:
+
+```sh
+make tidy
+```
+
+`make tidy` kann `go.mod`, `go.sum`, den Vendor-Bestand und Go-Quelltext verändern.
+Den resultierenden Diff deshalb immer prüfen.
+
+Die CI unter `.github/workflows/ci.yml` ist dafür konfiguriert, PostgreSQL zu
+migrieren, Module und Quelltext zu prüfen, alle Tests mit Race Detector auszuführen
+und alle drei Programme zu bauen.
+
+## Administration mit dem CLI
+
+Das CLI benötigt für Datenbankbefehle `GARDOMATIC_DB_DSN`. Globale Optionen stehen
+vor dem Unterbefehl. Mit `--json` liefert es maschinenlesbare Ausgabe; `--yes`
+bestätigt bewusst konfigurierte Produktionsoperationen.
+
+Häufige Befehle:
+
+```sh
+go run ./cmd/cli users list
+go run ./cmd/cli users show --email alice@example.com
+go run ./cmd/cli users invite --email alice@example.com --send-email
+go run ./cmd/cli users activate --email alice@example.com
+go run ./cmd/cli users deactivate --email alice@example.com
+go run ./cmd/cli users reset-password --email alice@example.com --generate-password
+go run ./cmd/cli db ping
+```
+
+Passwörter werden absichtlich nicht als Kommandozeilenargument angenommen. Sie
+werden sicher abgefragt, generiert oder mit `--password-stdin` von der
+Standardeingabe gelesen. Eine ausführliche Referenz mit Beispielen enthält
+[`doc/cli.md`](doc/cli.md).
+
+## Build
+
+Einzelne Programme für das lokale System und Linux/AMD64 bauen:
+
+```sh
+make build/api
+make build/web
+make build/cli
+```
+
+Alle Programme bauen:
+
+```sh
+make build/all
+```
+
+Artefakte landen unter `bin/` und werden nicht versioniert. Der `Dockerfile`
+enthält getrennte, minimale Laufzeit-Targets für API und Web.
+
+## Projektstruktur
+
+```text
+cmd/
+ api/ Start und Konfiguration der JSON-API
+ web/ Start und Konfiguration der Webanwendung
+ cli/ Administrations-CLI
+internal/
+ api/ API-Handler, Middleware und Fachabläufe
+ auth/ Passwort- und Tokenfunktionen
+ mailer/ Mailversand und Vorlagen
+ platform/ kleine technische Basispakete
+ storage/ Modelle und Storage-Schnittstellen
+ postgres/ PostgreSQL-Implementierungen und Migrationen
+ web/ Handler, Templates und statische Assets
+lib/
+ client/ typisierter Go-Client für die JSON-API
+doc/ Planung und weiterführende Dokumentation
+remote/ Produktionsbeispiele für systemd und Caddy
+request/ manuelle HTTP-Beispielanfragen
+```
+
+## Entwicklungskonventionen
+
+Bei Änderungen sind insbesondere folgende Grundsätze verbindlich:
+
+- idiomatischer, mit `gofmt` formatierter Go-Code
+- klare Verantwortlichkeiten und bestehende Schichtengrenzen
+- Wiederverwendung vorhandener Helfer und Fachlogik statt Codeduplizierung
+- Tests für neues oder korrigiertes Verhalten, soweit technisch möglich
+- neue Migrationen statt Änderungen an bereits veröffentlichten Migrationen
+- keine Zugangsdaten oder lokalen Umgebungsdateien im Repository
+
+Ausführliche Arbeitsregeln für Coding Agents und Beitragende stehen in
+[`AGENTS.md`](AGENTS.md).
+
+## Beiträge
+
+Beiträge sind willkommen. Der vollständige Ablauf, Qualitätsanforderungen und der
+Umgang mit Fremdmaterial sind in [`CONTRIBUTING.md`](CONTRIBUTING.md) beschrieben.
+
+Vor dem ersten Pull Request müssen Beitragende die
+[`Contributor License Agreement`](CLA.md) lesen und im Pull Request selbst
+akzeptieren. Beitragende behalten ihr Copyright, räumen dem Projektinhaber jedoch
+die notwendigen Rechte ein, den Beitrag sowohl unter der öffentlichen
+Projektlizenz als auch unter separaten kommerziellen oder proprietären Lizenzen zu
+verwenden. Beiträge im Namen eines Unternehmens müssen vorab abgestimmt werden.
+
+## Deployment
+
+Der `Dockerfile` kann Images für API und Web erzeugen. Unter `remote/production`
+liegen außerdem Beispielkonfigurationen für systemd und Caddy. Die
+`production/*`-Ziele im `Makefile` sind auf die vorhandene Gardomatic-Infrastruktur
+zugeschnitten, enthalten einen fest konfigurierten Zielhost und führen Migrationen
+sowie Dienstneustarts aus. Sie sind keine allgemeine Deployment-Anleitung und
+sollten vor jeder Verwendung geprüft werden.
+
+Die lokale Produktionsverbindung wird in `config.mk` konfiguriert. Eine kommentierte
+Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel und
+Zielarchitektur steht in `config.mk.example`. Der SSH-Admin ist der vom Hoster oder
+bei der LXC-Erstellung bereitgestellte Benutzer (`root`, `ubuntu` oder ähnlich)
+und benötigt Root-Rechte oder passwortloses `sudo`. Private SSH-Schlüssel bleiben
+ausschließlich auf dem lokalen Rechner; auf dem Server muss vorab nur der
+zugehörige öffentliche Schlüssel für diesen Admin hinterlegt sein.
+
+Das Server-Setup unter `remote/setup/provision-server.sh` liest seine Konfiguration
+aus `remote/setup/.env`. Als Ausgangspunkt dient `remote/setup/.env.example`; die
+echte Datei muss auf Modus `0600` gesetzt werden und bleibt von Git ausgeschlossen.
+`make production/provision` überträgt das Skript und streamt die Konfiguration über
+SSH, ohne die Quelldatei dauerhaft auf dem Server abzulegen. Das Provisioning legt
+den gesperrten Servicebenutzer `gardomatic` ohne Login, SSH-Schlüssel oder
+sudo-Rechte an und installiert die Laufzeitwerte als
+`/etc/gardomatic/gardomatic.env`.
+
+Ein vollständiger Erstbetrieb besteht aus:
+
+```sh
+make config/init
+# Die benötigten lokalen Konfigurationsdateien bearbeiten, dann:
+make production/provision
+make production/deploy
+make production/create-admin
+```
+
+`production/deploy` überträgt Binärdateien, Migrationen, systemd-Units und die
+Admin-Hilfe über denselben SSH-Admin, wendet Migrationen an und startet die Dienste.
+`production/create-admin` fragt interaktiv nach Name und E-Mail und erstellt den
+Benutzer aktiviert und mit der Rolle `application:admin`; das sichere generierte
+Passwort wird einmalig ausgegeben.
+
+Für Produktion gelten mindestens folgende Anforderungen:
+
+- `GARDOMATIC_ENV=production`
+- HTTPS am Reverse Proxy
+- `GARDOMATIC_COOKIE_SECURE=true`
+- starke, extern verwaltete Zugangsdaten
+- eingeschränkter Datenbankzugriff und regelmäßige Backups
+- korrekte öffentliche Web-URL und vertrauenswürdige CORS-Origins
+- SMTP statt dateibasiertem Mailversand, sofern E-Mails zugestellt werden sollen
+
+## Weiterführende Dokumentation
+
+- [`doc/planung.md`](doc/planung.md) – Produktbeschreibung, Leitplanken und Fahrplan
+- [`doc/cli.md`](doc/cli.md) – vollständige Bedienung des Administrations-CLI
+- [`doc/issues.md`](doc/issues.md) – bekannte Themen und Arbeitsnotizen
+- [`doc/ideen.md`](doc/ideen.md) – mögliche spätere Erweiterungen
+
+## Lizenz
+
+Gardomatic steht unter der
+[PolyForm Noncommercial License 1.0.0](LICENSE). Sie erlaubt Nutzung, Veränderung
+und Weitergabe für nichtkommerzielle Zwecke unter den Bedingungen der Lizenz.
+
+Kommerzielle Nutzung ist von dieser Lizenz nicht abgedeckt und erfordert eine
+separate, kostenpflichtige Lizenzvereinbarung. Anfragen können an
+[alex@kleiax.de](mailto:alex@kleiax.de) gerichtet werden.
+
+Für Beiträge Dritter gilt zusätzlich die
+[`Gardomatic Contributor License Agreement`](CLA.md).
+
+Wegen des Ausschlusses kommerzieller Nutzung ist Gardomatic „source-available“,
+aber keine Open-Source-Software nach der Definition der Open Source Initiative.
diff --git a/cmd/api/config.go b/cmd/api/config.go
new file mode 100644
index 0000000..906c74e
--- /dev/null
+++ b/cmd/api/config.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/api"
+ "gardomatic.kleiax.de/internal/mailer"
+ "gardomatic.kleiax.de/internal/platform/environment"
+)
+
+func configFromEnvironment() (api.Config, error) {
+ var errs []error
+ required := func(name string) string {
+ value, err := environment.Required(name)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ return value
+ }
+ integer := func(name string, fallback int) int {
+ value, err := environment.Int(name, fallback)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ return value
+ }
+ boolean := func(name string, fallback bool) bool {
+ value, err := environment.Bool(name, fallback)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ return value
+ }
+ duration := func(name string, fallback time.Duration) time.Duration {
+ value, err := environment.Duration(name, fallback)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ return value
+ }
+ floating := func(name string, fallback float64) float64 {
+ value, err := environment.Float(name, fallback)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ return value
+ }
+
+ cfg := api.Config{
+ Host: environment.String("GARDOMATIC_API_HOST", ""),
+ Port: integer("GARDOMATIC_API_PORT", 4000),
+ Env: environment.String("GARDOMATIC_ENV", "development"),
+ WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
+ 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),
+ },
+ Limiter: api.LimiterConfig{Enabled: boolean("GARDOMATIC_RATE_LIMIT_ENABLED", true), Rps: floating("GARDOMATIC_RATE_LIMIT_RPS", 10), Burst: integer("GARDOMATIC_RATE_LIMIT_BURST", 40)},
+ Session: api.SessionConfig{Lifetime: duration("GARDOMATIC_SESSION_LIFETIME", 12*time.Hour), IdleTimeout: duration("GARDOMATIC_SESSION_IDLE_TIMEOUT", 30*time.Minute), CookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: boolean("GARDOMATIC_COOKIE_SECURE", false)},
+ Mail: mailer.Config{Mode: mailer.Mode(environment.String("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), Host: environment.String("GARDOMATIC_SMTP_HOST", ""), Port: integer("GARDOMATIC_SMTP_PORT", 25), Username: environment.String("GARDOMATIC_SMTP_USERNAME", ""), Password: environment.String("GARDOMATIC_SMTP_PASSWORD", ""), Sender: environment.String("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), FilePath: environment.String("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log")},
+ Cors: api.CORSConfig{TrustedOrigins: environment.CSV("GARDOMATIC_CORS_TRUSTED_ORIGINS")},
+ }
+
+ if cfg.Port < 1 || cfg.Port > 65535 {
+ errs = append(errs, errors.New("GARDOMATIC_API_PORT must be between 1 and 65535"))
+ }
+ if cfg.Env != "development" && cfg.Env != "test" && cfg.Env != "production" {
+ errs = append(errs, fmt.Errorf("GARDOMATIC_ENV has unsupported value %q", cfg.Env))
+ }
+ if cfg.DB.MaxOpenConns < 1 || cfg.DB.MaxIdleConns < 0 || cfg.DB.MaxIdleConns > cfg.DB.MaxOpenConns {
+ errs = append(errs, errors.New("database pool limits are invalid"))
+ }
+ if cfg.Limiter.Rps <= 0 || cfg.Limiter.Burst < 1 {
+ errs = append(errs, errors.New("rate limit values must be positive"))
+ }
+ if strings.TrimSpace(cfg.Session.CookieName) == "" {
+ errs = append(errs, errors.New("GARDOMATIC_SESSION_COOKIE_NAME must not be empty"))
+ }
+ for _, origin := range cfg.Cors.TrustedOrigins {
+ parsed, err := url.ParseRequestURI(origin)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ errs = append(errs, fmt.Errorf("invalid trusted origin %q", origin))
+ }
+ }
+ if parsed, err := url.ParseRequestURI(cfg.WebBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ errs = append(errs, errors.New("GARDOMATIC_WEB_BASE_URL must be an absolute URL"))
+ }
+ if cfg.Env == "production" && !cfg.Session.CookieSecure {
+ errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
+ }
+ if _, err := mailer.New(cfg.Mail); err != nil {
+ errs = append(errs, fmt.Errorf("mail configuration: %w", err))
+ }
+ return cfg, errors.Join(errs...)
+}
diff --git a/cmd/api/config_test.go b/cmd/api/config_test.go
new file mode 100644
index 0000000..ac60c5d
--- /dev/null
+++ b/cmd/api/config_test.go
@@ -0,0 +1,24 @@
+package main
+
+import "testing"
+
+func TestConfigFromEnvironment(t *testing.T) {
+ t.Setenv("GARDOMATIC_DB_DSN", "postgres://example")
+ 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")
+ cfg, err := configFromEnvironment()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 {
+ t.Fatalf("unexpected config: %#v", cfg)
+ }
+}
+
+func TestConfigRequiresDSN(t *testing.T) {
+ t.Setenv("GARDOMATIC_DB_DSN", "")
+ if _, err := configFromEnvironment(); err == nil {
+ t.Fatal("expected missing DSN error")
+ }
+}
diff --git a/cmd/api/doc.go b/cmd/api/doc.go
new file mode 100644
index 0000000..0d824e6
--- /dev/null
+++ b/cmd/api/doc.go
@@ -0,0 +1,2 @@
+// Package main starts the Gardomatic JSON API service.
+package main
diff --git a/cmd/api/main.go b/cmd/api/main.go
new file mode 100644
index 0000000..f167571
--- /dev/null
+++ b/cmd/api/main.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "log"
+
+ "gardomatic.kleiax.de/internal/api"
+)
+
+func main() {
+ cfg, err := configFromEnvironment()
+ if err != nil {
+ log.Fatal(err)
+ }
+ server := api.New(cfg)
+ server.Run()
+}
diff --git a/cmd/cli/application.go b/cmd/cli/application.go
new file mode 100644
index 0000000..305012b
--- /dev/null
+++ b/cmd/cli/application.go
@@ -0,0 +1,663 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "slices"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "gardomatic.kleiax.de/internal/mailer"
+ "gardomatic.kleiax.de/internal/platform/validate"
+ "gardomatic.kleiax.de/internal/storage"
+ "gardomatic.kleiax.de/internal/vcs"
+ "golang.org/x/term"
+)
+
+const activationTokenTTL = 3 * 24 * time.Hour
+
+type application struct {
+ stdin io.Reader
+ stdout io.Writer
+ stderr io.Writer
+ openStore func(string) (adminStore, error)
+ newMailer func(mailer.Config) (*mailer.Mailer, error)
+}
+
+func newApplication(stdin io.Reader, stdout, stderr io.Writer) *application {
+ return &application{
+ stdin: stdin,
+ stdout: stdout,
+ stderr: stderr,
+ openStore: openPostgresStore,
+ newMailer: mailer.New,
+ }
+}
+
+func (app *application) run(ctx context.Context, args []string) error {
+ cfg, err := configFromEnvironment()
+ if err != nil {
+ return err
+ }
+
+ global := flag.NewFlagSet("gardomatic", flag.ContinueOnError)
+ global.SetOutput(app.stderr)
+ global.StringVar(&cfg.dsn, "dsn", cfg.dsn, "PostgreSQL DSN (default: GARDOMATIC_DB_DSN)")
+ global.StringVar(&cfg.environment, "env", cfg.environment, "environment name")
+ global.StringVar(&cfg.webBaseURL, "web-base-url", cfg.webBaseURL, "public web base URL")
+ global.BoolVar(&cfg.json, "json", false, "write JSON output")
+ global.BoolVar(&cfg.yes, "yes", false, "skip production confirmation")
+ global.Usage = func() { app.printUsage(global) }
+ if err = global.Parse(args); err != nil {
+ return err
+ }
+
+ remaining := global.Args()
+ if len(remaining) == 0 {
+ global.Usage()
+ return nil
+ }
+ if remaining[0] == "help" || remaining[0] == "--help" || remaining[0] == "-h" {
+ global.Usage()
+ return nil
+ }
+ if slices.Contains(remaining[1:], "--help") || slices.Contains(remaining[1:], "-h") {
+ global.Usage()
+ return nil
+ }
+ if remaining[0] == "version" {
+ return app.writeValue(cfg, map[string]string{"version": vcs.Version()}, "Version: %s\n", vcs.Version())
+ }
+
+ store, err := app.openStore(cfg.dsn)
+ if err != nil {
+ return err
+ }
+ defer store.Close()
+
+ switch remaining[0] {
+ case "users":
+ return app.runUsers(ctx, cfg, store, remaining[1:])
+ case "gardens":
+ return app.runGardens(ctx, cfg, store, remaining[1:])
+ case "db":
+ return app.runDB(ctx, cfg, store, remaining[1:])
+ default:
+ return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
+ }
+}
+
+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")
+ }
+ switch args[0] {
+ case "create":
+ return app.createUser(ctx, cfg, store, args[1:])
+ case "list":
+ return app.listUsers(ctx, cfg, store, args[1:])
+ case "show":
+ return app.showUser(ctx, cfg, store, args[1:])
+ case "activate":
+ return app.setUserActivated(ctx, cfg, store, args[1:], true)
+ case "deactivate":
+ return app.setUserActivated(ctx, cfg, store, args[1:], false)
+ case "invite":
+ return app.inviteUser(ctx, cfg, store, args[1:])
+ case "reset-password":
+ return app.resetPassword(ctx, cfg, store, args[1:])
+ case "set-role":
+ return app.setUserRole(ctx, cfg, store, args[1:])
+ default:
+ return fmt.Errorf("unknown users command %q", args[0])
+ }
+}
+
+func (app *application) runGardens(ctx context.Context, cfg config, store adminStore, args []string) error {
+ if len(args) == 0 {
+ return errors.New("missing gardens command: add-user")
+ }
+ if args[0] != "add-user" {
+ return fmt.Errorf("unknown gardens command %q", args[0])
+ }
+ return app.addGardenMember(ctx, cfg, store, args[1:])
+}
+
+func (app *application) setUserRole(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users set-role", app.stderr)
+ var email, role string
+ fs.StringVar(&email, "email", "", "email address (required)")
+ fs.StringVar(&role, "role", "", "application role (required)")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ email, role = strings.TrimSpace(email), strings.TrimSpace(role)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) {
+ return errors.New("role must be application:user or application:admin")
+ }
+ user, err := store.SetUserRole(ctx, email, role)
+ if err != nil {
+ return userError(email, err)
+ }
+ return app.writeUser(cfg, user)
+}
+
+func (app *application) addGardenMember(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("gardens add-user", app.stderr)
+ var gardenID int
+ var email, role string
+ fs.IntVar(&gardenID, "garden-id", 0, "garden ID (required)")
+ fs.StringVar(&email, "email", "", "email address (required)")
+ fs.StringVar(&role, "role", string(storage.GardenRoleMember), "garden role")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ email, role = strings.TrimSpace(email), strings.TrimSpace(role)
+ if gardenID < 1 {
+ return errors.New("garden-id must be a positive integer")
+ }
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ validRole := role == string(storage.GardenRoleOwner) || role == string(storage.GardenRoleAdmin) || role == string(storage.GardenRoleMember) || role == string(storage.GardenRoleViewer) || role == string(storage.GardenRoleWorker)
+ if !validRole {
+ return errors.New("role must be owner, admin, member, viewer, or worker")
+ }
+ member, err := store.AddGardenMember(ctx, gardenID, email, role)
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return fmt.Errorf("garden %d, user %s, or role %s was not found", gardenID, email, role)
+ }
+ return err
+ }
+ if cfg.json {
+ return writeJSON(app.stdout, member)
+ }
+ _, err = fmt.Fprintf(app.stdout, "User: %s\nGarden: %d\nRole: %s\n", member.Email, member.GardenID, member.Role)
+ return err
+}
+
+func (app *application) createUser(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users create", app.stderr)
+ var name, email, role string
+ var active, invite, generatePassword, passwordStdin, sendEmail bool
+ fs.StringVar(&name, "name", "", "display name (required)")
+ fs.StringVar(&email, "email", "", "email address (required)")
+ fs.StringVar(&role, "role", string(storage.ApplicationRoleUser), "application role")
+ fs.BoolVar(&active, "active", false, "create an activated account")
+ fs.BoolVar(&invite, "invite", false, "create an activation token")
+ fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password")
+ fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input")
+ fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ name = strings.TrimSpace(name)
+ email = strings.TrimSpace(email)
+ role = strings.TrimSpace(role)
+ if active == invite {
+ return errors.New("exactly one of --active or --invite is required")
+ }
+ if sendEmail && !invite {
+ return errors.New("--send-email requires --invite")
+ }
+ if generatePassword && passwordStdin {
+ return errors.New("--generate-password and --password-stdin are mutually exclusive")
+ }
+ if err := validateIdentity(name, email); err != nil {
+ return err
+ }
+ if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) {
+ return errors.New("role must be application:user or application:admin")
+ }
+ password, generated, err := app.obtainPassword(generatePassword, passwordStdin)
+ if err != nil {
+ return err
+ }
+ passwordHash, err := hashPassword(password)
+ if err != nil {
+ return err
+ }
+
+ result, err := store.CreateUser(ctx, createUserInput{
+ Name: name,
+ Email: email,
+ PasswordHash: passwordHash,
+ Activated: active,
+ Role: role,
+ Invite: invite,
+ TokenTTL: activationTokenTTL,
+ })
+ if err != nil {
+ if errors.Is(err, storage.ErrDuplicateEmail) {
+ return fmt.Errorf("a user with email %s already exists", email)
+ }
+ return err
+ }
+
+ output := userMutationOutput{User: result.User}
+ if generated {
+ output.GeneratedPassword = password
+ }
+ if result.Token != nil {
+ output.ActivationToken = result.Token.Plaintext
+ output.ActivationURL, err = activationURL(cfg.webBaseURL, result.Token.Plaintext)
+ if err != nil {
+ return err
+ }
+ output.TokenExpiry = &result.Token.Expiry
+ if sendEmail {
+ if err = app.sendInvitation(cfg, result.User, *result.Token, output.ActivationURL, true); err != nil {
+ return fmt.Errorf("user was created, but sending invitation failed: %w", err)
+ }
+ output.EmailSent = true
+ }
+ }
+ return app.writeUserMutation(cfg, output)
+}
+
+func (app *application) listUsers(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users list", app.stderr)
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ users, err := store.ListUsers(ctx)
+ if err != nil {
+ return err
+ }
+ if cfg.json {
+ return writeJSON(app.stdout, users)
+ }
+ tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(tw, "ID\tNAME\tEMAIL\tACTIVE\tROLE\tCREATED")
+ for _, user := range users {
+ fmt.Fprintf(tw, "%d\t%s\t%s\t%t\t%s\t%s\n", user.ID, user.Name, user.Email, user.Activated, user.Role, user.CreatedAt.Format(time.RFC3339))
+ }
+ return tw.Flush()
+}
+
+func (app *application) showUser(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users show", app.stderr)
+ var email string
+ fs.StringVar(&email, "email", "", "email address (required)")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ user, err := store.GetUser(ctx, strings.TrimSpace(email))
+ if err != nil {
+ return userError(email, err)
+ }
+ return app.writeUser(cfg, user)
+}
+
+func (app *application) setUserActivated(ctx context.Context, cfg config, store adminStore, args []string, active bool) error {
+ command := "activate"
+ if !active {
+ command = "deactivate"
+ }
+ fs := newFlagSet("users "+command, app.stderr)
+ var email string
+ fs.StringVar(&email, "email", "", "email address (required)")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ email = strings.TrimSpace(email)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ if !active {
+ if err := app.confirmProduction(cfg, "deactivate user "+email); err != nil {
+ return err
+ }
+ }
+ user, err := store.SetActivated(ctx, email, active)
+ if err != nil {
+ return userError(email, err)
+ }
+ return app.writeUser(cfg, user)
+}
+
+func (app *application) inviteUser(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users invite", app.stderr)
+ var email string
+ var sendEmail bool
+ fs.StringVar(&email, "email", "", "email address (required)")
+ fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ email = strings.TrimSpace(email)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ user, token, err := store.IssueInvitation(ctx, email, activationTokenTTL)
+ if err != nil {
+ return userError(email, err)
+ }
+ link, err := activationURL(cfg.webBaseURL, token.Plaintext)
+ if err != nil {
+ return err
+ }
+ output := userMutationOutput{
+ User: user,
+ ActivationToken: token.Plaintext,
+ ActivationURL: link,
+ TokenExpiry: &token.Expiry,
+ }
+ if sendEmail {
+ if err = app.sendInvitation(cfg, user, token, link, false); err != nil {
+ return fmt.Errorf("invitation token was created, but sending email failed: %w", err)
+ }
+ output.EmailSent = true
+ }
+ return app.writeUserMutation(cfg, output)
+}
+
+func (app *application) resetPassword(ctx context.Context, cfg config, store adminStore, args []string) error {
+ fs := newFlagSet("users reset-password", app.stderr)
+ var email string
+ var generatePassword, passwordStdin bool
+ fs.StringVar(&email, "email", "", "email address (required)")
+ fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password")
+ fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input")
+ if err := parseFlags(fs, args); err != nil {
+ return err
+ }
+ email = strings.TrimSpace(email)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ if generatePassword && passwordStdin {
+ return errors.New("--generate-password and --password-stdin are mutually exclusive")
+ }
+ if err := app.confirmProduction(cfg, "reset password for "+email); err != nil {
+ return err
+ }
+ password, generated, err := app.obtainPassword(generatePassword, passwordStdin)
+ if err != nil {
+ return err
+ }
+ hash, err := hashPassword(password)
+ if err != nil {
+ return err
+ }
+ user, err := store.ResetPassword(ctx, email, hash)
+ if err != nil {
+ return userError(email, err)
+ }
+ output := userMutationOutput{User: user}
+ if generated {
+ output.GeneratedPassword = password
+ }
+ return app.writeUserMutation(cfg, output)
+}
+
+func (app *application) runDB(ctx context.Context, cfg config, store adminStore, args []string) error {
+ if len(args) != 1 || args[0] != "ping" {
+ return errors.New("usage: gardomatic db ping")
+ }
+ if err := store.Ping(ctx); err != nil {
+ return err
+ }
+ return app.writeValue(cfg, map[string]string{"status": "ok"}, "database: ok\n")
+}
+
+type userMutationOutput struct {
+ User userView `json:"user"`
+ ActivationToken string `json:"activation_token,omitempty"`
+ ActivationURL string `json:"activation_url,omitempty"`
+ TokenExpiry *time.Time `json:"token_expiry,omitempty"`
+ GeneratedPassword string `json:"generated_password,omitempty"`
+ EmailSent bool `json:"email_sent,omitempty"`
+}
+
+func (app *application) writeUserMutation(cfg config, output userMutationOutput) error {
+ if cfg.json {
+ return writeJSON(app.stdout, output)
+ }
+ if err := app.writeUser(cfg, output.User); err != nil {
+ return err
+ }
+ if output.GeneratedPassword != "" {
+ fmt.Fprintf(app.stdout, "Generated password: %s\n", output.GeneratedPassword)
+ }
+ if output.ActivationToken != "" {
+ fmt.Fprintf(app.stdout, "Activation token: %s\n", output.ActivationToken)
+ fmt.Fprintf(app.stdout, "Activation URL: %s\n", output.ActivationURL)
+ fmt.Fprintf(app.stdout, "Token expires: %s\n", output.TokenExpiry.Format(time.RFC3339))
+ }
+ if output.EmailSent {
+ fmt.Fprintln(app.stdout, "Invitation email sent.")
+ }
+ return nil
+}
+
+func (app *application) writeUser(cfg config, user userView) error {
+ if cfg.json {
+ return writeJSON(app.stdout, user)
+ }
+ tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintf(tw, "ID:\t%d\n", user.ID)
+ fmt.Fprintf(tw, "Name:\t%s\n", user.Name)
+ fmt.Fprintf(tw, "Email:\t%s\n", user.Email)
+ fmt.Fprintf(tw, "Active:\t%t\n", user.Activated)
+ fmt.Fprintf(tw, "Role:\t%s\n", user.Role)
+ return tw.Flush()
+}
+
+func (app *application) writeValue(cfg config, value any, format string, args ...any) error {
+ if cfg.json {
+ return writeJSON(app.stdout, value)
+ }
+ _, err := fmt.Fprintf(app.stdout, format, args...)
+ return err
+}
+
+func (app *application) obtainPassword(generate, fromStdin bool) (string, bool, error) {
+ if generate {
+ return rand.Text(), true, nil
+ }
+ if fromStdin {
+ password, err := bufio.NewReader(app.stdin).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return "", false, err
+ }
+ password = strings.TrimRight(password, "\r\n")
+ if err = validatePassword(password); err != nil {
+ return "", false, err
+ }
+ return password, false, nil
+ }
+
+ input, ok := app.stdin.(*os.File)
+ if !ok || !term.IsTerminal(int(input.Fd())) {
+ return "", false, errors.New("standard input is not a terminal; use --password-stdin or --generate-password")
+ }
+ fmt.Fprint(app.stderr, "Password: ")
+ first, err := term.ReadPassword(int(input.Fd()))
+ fmt.Fprintln(app.stderr)
+ if err != nil {
+ return "", false, err
+ }
+ fmt.Fprint(app.stderr, "Repeat password: ")
+ second, err := term.ReadPassword(int(input.Fd()))
+ fmt.Fprintln(app.stderr)
+ if err != nil {
+ return "", false, err
+ }
+ if string(first) != string(second) {
+ return "", false, errors.New("passwords do not match")
+ }
+ if err = validatePassword(string(first)); err != nil {
+ return "", false, err
+ }
+ return string(first), false, nil
+}
+
+func (app *application) confirmProduction(cfg config, action string) error {
+ if !strings.EqualFold(cfg.environment, "production") || cfg.yes {
+ return nil
+ }
+ fmt.Fprintf(app.stderr, "Production: %s. Continue? [y/N] ", action)
+ answer, err := bufio.NewReader(app.stdin).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ return err
+ }
+ if strings.ToLower(strings.TrimSpace(answer)) != "y" {
+ return errors.New("operation cancelled")
+ }
+ return nil
+}
+
+func (app *application) sendInvitation(cfg config, user userView, token auth.Token, link string, welcome bool) error {
+ m, err := app.newMailer(cfg.mail)
+ if err != nil {
+ return err
+ }
+ template := "token_activation.tmpl"
+ if welcome {
+ template = "user_welcome.tmpl"
+ }
+ return m.Send(user.Email, template, map[string]any{
+ "activationToken": token.Plaintext,
+ "activationURL": link,
+ "userID": user.ID,
+ })
+}
+
+func (app *application) printUsage(fs *flag.FlagSet) {
+ fmt.Fprintln(app.stderr, `Gardomatic administration CLI
+
+Usage:
+ gardomatic [global options] users create --name NAME --email EMAIL (--active|--invite) [--role ROLE] [options]
+ gardomatic [global options] users list
+ gardomatic [global options] users show --email EMAIL
+ gardomatic [global options] users activate|deactivate --email EMAIL
+ gardomatic [global options] users invite --email EMAIL [--send-email]
+ gardomatic [global options] users reset-password --email EMAIL [options]
+ 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 [global options] version
+
+Global options:`)
+ fs.PrintDefaults()
+}
+
+func activationURL(baseURL, token string) (string, error) {
+ u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/activate")
+ if err != nil {
+ return "", fmt.Errorf("invalid web base URL: %w", err)
+ }
+ if u.Scheme == "" || u.Host == "" {
+ return "", errors.New("web base URL must include scheme and host")
+ }
+ query := u.Query()
+ query.Set("token", token)
+ u.RawQuery = query.Encode()
+ return u.String(), nil
+}
+
+func validateIdentity(name, email string) error {
+ password := auth.NewPassword([]byte("placeholder"))
+ user := storage.User{Name: name, Email: email, Password: *password}
+ v := validate.New()
+ storage.ValidateUser(v, user)
+ delete(v.Errors, "password")
+ if !v.Valid() {
+ return validationError(v.Errors)
+ }
+ return nil
+}
+
+func validateEmail(email string) error {
+ v := validate.New()
+ storage.ValidateEmail(v, strings.TrimSpace(email))
+ if !v.Valid() {
+ return validationError(v.Errors)
+ }
+ return nil
+}
+
+func validatePassword(password string) error {
+ v := validate.New()
+ auth.ValidatePasswordPlaintext(v, password)
+ if !v.Valid() {
+ return validationError(v.Errors)
+ }
+ return nil
+}
+
+func hashPassword(plaintext string) ([]byte, error) {
+ if err := validatePassword(plaintext); err != nil {
+ return nil, err
+ }
+ var password auth.Password
+ if err := password.Set(plaintext); err != nil {
+ return nil, err
+ }
+ return password.Get(), nil
+}
+
+func validationError(fields map[string]string) error {
+ keys := make([]string, 0, len(fields))
+ for key := range fields {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ parts := make([]string, 0, len(keys))
+ for _, key := range keys {
+ parts = append(parts, key+" "+fields[key])
+ }
+ return errors.New(strings.Join(parts, "; "))
+}
+
+func userError(email string, err error) error {
+ switch {
+ case errors.Is(err, errUserNotFound):
+ return fmt.Errorf("no user found with email %s", email)
+ case errors.Is(err, errUserAlreadyActive):
+ return fmt.Errorf("user %s is already active", email)
+ default:
+ return err
+ }
+}
+
+func newFlagSet(name string, output io.Writer) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(output)
+ return fs
+}
+
+func parseFlags(fs *flag.FlagSet, args []string) error {
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() != 0 {
+ return fmt.Errorf("unexpected argument %q", fs.Arg(0))
+ }
+ return nil
+}
+
+func writeJSON(w io.Writer, value any) error {
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ encoder.SetEscapeHTML(false)
+ return encoder.Encode(value)
+}
diff --git a/cmd/cli/application_test.go b/cmd/cli/application_test.go
new file mode 100644
index 0000000..c360c3c
--- /dev/null
+++ b/cmd/cli/application_test.go
@@ -0,0 +1,272 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type fakeAdminStore struct {
+ createInput createUserInput
+ createCalls int
+ setActivatedCalls int
+ inviteCalls int
+ setRoleCalls int
+ addMemberCalls int
+ lastRole string
+}
+
+func (s *fakeAdminStore) Ping(context.Context) error { return nil }
+func (s *fakeAdminStore) Close() error { return nil }
+
+func (s *fakeAdminStore) CreateUser(_ context.Context, input createUserInput) (createUserResult, error) {
+ s.createCalls++
+ s.createInput = input
+ result := createUserResult{User: userView{
+ ID: 42,
+ Name: input.Name,
+ Email: input.Email,
+ Activated: input.Activated,
+ Role: input.Role,
+ CreatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
+ }}
+ if input.Invite {
+ token := auth.NewToken(42, input.TokenTTL, auth.ScopeActivation)
+ result.Token = &token
+ }
+ return result, nil
+}
+
+func (s *fakeAdminStore) ListUsers(context.Context) ([]userView, error) { return nil, nil }
+func (s *fakeAdminStore) GetUser(context.Context, string) (userView, error) {
+ return userView{}, errUserNotFound
+}
+func (s *fakeAdminStore) SetActivated(_ context.Context, email string, active bool) (userView, error) {
+ s.setActivatedCalls++
+ return userView{ID: 42, Email: email, Activated: active}, nil
+}
+func (s *fakeAdminStore) IssueInvitation(_ context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
+ s.inviteCalls++
+ return userView{ID: 42, Name: "Alice", Email: email}, auth.NewToken(42, ttl, auth.ScopeActivation), nil
+}
+func (s *fakeAdminStore) ResetPassword(context.Context, string, []byte) (userView, error) {
+ return userView{}, nil
+}
+func (s *fakeAdminStore) SetUserRole(_ context.Context, email, role string) (userView, error) {
+ s.setRoleCalls++
+ s.lastRole = role
+ return userView{ID: 42, Email: email, Role: role}, nil
+}
+func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email, role string) (gardenMemberView, error) {
+ s.addMemberCalls++
+ s.lastRole = role
+ return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
+}
+
+func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
+ t.Helper()
+ t.Setenv("GARDOMATIC_DB_DSN", "postgres://unused")
+ t.Setenv("GARDOMATIC_ENV", "development")
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ app := newApplication(strings.NewReader(stdin), stdout, stderr)
+ app.openStore = func(string) (adminStore, error) { return store, nil }
+ return app, stdout, stderr
+}
+
+func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, stdout, _ := newTestApplication(t, "", store)
+
+ err := app.run(context.Background(), []string{
+ "--json",
+ "--web-base-url", "https://gardomatic.example/app",
+ "users", "create",
+ "--name", " Alice ",
+ "--email", "alice@example.com",
+ "--invite",
+ "--generate-password",
+ })
+ if err != nil {
+ t.Fatalf("run() returned an error: %v", err)
+ }
+ if store.createCalls != 1 {
+ t.Fatalf("CreateUser calls = %d, want 1", store.createCalls)
+ }
+ if store.createInput.Name != "Alice" || !store.createInput.Invite || store.createInput.Activated {
+ t.Errorf("CreateUser input = %+v", store.createInput)
+ }
+
+ var output userMutationOutput
+ if err = json.Unmarshal(stdout.Bytes(), &output); err != nil {
+ t.Fatalf("decoding output: %v; output: %s", err, stdout.String())
+ }
+ if output.GeneratedPassword == "" {
+ t.Fatal("generated password is missing")
+ }
+ if err = bcrypt.CompareHashAndPassword(store.createInput.PasswordHash, []byte(output.GeneratedPassword)); err != nil {
+ t.Errorf("stored password hash does not match generated password: %v", err)
+ }
+ if !strings.HasPrefix(output.ActivationURL, "https://gardomatic.example/app/activate?token=") {
+ t.Errorf("activation URL = %q", output.ActivationURL)
+ }
+ if output.ActivationToken == "" || !strings.Contains(output.ActivationURL, output.ActivationToken) {
+ t.Errorf("activation token and URL do not match: %+v", output)
+ }
+}
+
+func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, _, _ := newTestApplication(t, "", store)
+
+ err := app.run(context.Background(), []string{
+ "users", "create",
+ "--name", "Alice",
+ "--email", "alice@example.com",
+ "--generate-password",
+ })
+ if err == nil || !strings.Contains(err.Error(), "exactly one") {
+ t.Fatalf("run() error = %v, want account mode error", err)
+ }
+ if store.createCalls != 0 {
+ t.Errorf("CreateUser calls = %d, want 0", store.createCalls)
+ }
+}
+
+func TestCreateUserCanAtomicallyCreateApplicationAdmin(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, stdout, _ := newTestApplication(t, "", store)
+
+ err := app.run(context.Background(), []string{
+ "users", "create",
+ "--name", "Initial Admin",
+ "--email", "admin@example.com",
+ "--role", "application:admin",
+ "--active",
+ "--generate-password",
+ })
+ if err != nil {
+ t.Fatalf("run() returned an error: %v", err)
+ }
+ if store.createInput.Role != "application:admin" {
+ t.Fatalf("created role = %q, want application:admin", store.createInput.Role)
+ }
+ if !strings.Contains(stdout.String(), "application:admin") {
+ t.Fatalf("output does not contain admin role: %s", stdout.String())
+ }
+}
+
+func TestCreateUserRejectsUnknownApplicationRole(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, _, _ := newTestApplication(t, "", store)
+ err := app.run(context.Background(), []string{
+ "users", "create",
+ "--name", "Alice",
+ "--email", "alice@example.com",
+ "--role", "superadmin",
+ "--active",
+ "--generate-password",
+ })
+ if err == nil || !strings.Contains(err.Error(), "role must be") {
+ t.Fatalf("run() error = %v, want role validation error", err)
+ }
+ if store.createCalls != 0 {
+ t.Fatalf("CreateUser calls = %d, want 0", store.createCalls)
+ }
+}
+
+func TestProductionDeactivationRequiresConfirmation(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, _, _ := newTestApplication(t, "n\n", store)
+
+ err := app.run(context.Background(), []string{
+ "--env", "production",
+ "users", "deactivate", "--email", "alice@example.com",
+ })
+ if err == nil || err.Error() != "operation cancelled" {
+ t.Fatalf("run() error = %v, want operation cancelled", err)
+ }
+ if store.setActivatedCalls != 0 {
+ t.Errorf("SetActivated calls = %d, want 0", store.setActivatedCalls)
+ }
+}
+
+func TestInviteCanWriteEmailWithActivationLink(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, _, _ := newTestApplication(t, "", store)
+ mailPath := filepath.Join(t.TempDir(), "mail.log")
+ t.Setenv("GARDOMATIC_SMTP_MODE", "file")
+ t.Setenv("GARDOMATIC_SMTP_FILE_PATH", mailPath)
+
+ err := app.run(context.Background(), []string{
+ "--web-base-url", "https://gardomatic.example",
+ "users", "invite", "--email", "alice@example.com", "--send-email",
+ })
+ if err != nil {
+ t.Fatalf("run() returned an error: %v", err)
+ }
+ content, err := os.ReadFile(mailPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(content), "https://gardomatic.example/activate?token=") {
+ t.Errorf("mail does not contain activation URL: %s", content)
+ }
+}
+
+func TestActivationURLRejectsRelativeBase(t *testing.T) {
+ _, err := activationURL("localhost:4040", "token")
+ if err == nil {
+ t.Fatal("activationURL() returned no error")
+ }
+}
+
+func TestSetUserRoleCanMakeApplicationAdmin(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, stdout, _ := newTestApplication(t, "", store)
+ err := app.run(context.Background(), []string{"users", "set-role", "--email", "alice@example.com", "--role", "application:admin"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if store.setRoleCalls != 1 || store.lastRole != "application:admin" || !strings.Contains(stdout.String(), "application:admin") {
+ t.Fatalf("role update missing: calls=%d role=%q output=%q", store.setRoleCalls, store.lastRole, stdout.String())
+ }
+}
+
+func TestAddUserToGardenWithAdminRole(t *testing.T) {
+ store := new(fakeAdminStore)
+ app, stdout, _ := newTestApplication(t, "", store)
+ err := app.run(context.Background(), []string{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "admin"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if store.addMemberCalls != 1 || store.lastRole != "admin" || !strings.Contains(stdout.String(), "Garden: 7") {
+ t.Fatalf("garden membership missing: calls=%d role=%q output=%q", store.addMemberCalls, store.lastRole, stdout.String())
+ }
+}
+
+func TestRoleCommandsRejectUnknownRoles(t *testing.T) {
+ tests := [][]string{
+ {"users", "set-role", "--email", "alice@example.com", "--role", "superadmin"},
+ {"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "superadmin"},
+ }
+ for _, args := range tests {
+ store := new(fakeAdminStore)
+ app, _, _ := newTestApplication(t, "", store)
+ if err := app.run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "role must be") {
+ t.Errorf("run(%v) error = %v, want role validation error", args, err)
+ }
+ if store.setRoleCalls != 0 || store.addMemberCalls != 0 {
+ t.Errorf("run(%v) reached store", args)
+ }
+ }
+}
diff --git a/cmd/cli/config.go b/cmd/cli/config.go
new file mode 100644
index 0000000..7c4349b
--- /dev/null
+++ b/cmd/cli/config.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "gardomatic.kleiax.de/internal/mailer"
+)
+
+type config struct {
+ dsn string
+ environment string
+ webBaseURL string
+ json bool
+ yes bool
+ mail mailer.Config
+}
+
+func configFromEnvironment() (config, error) {
+ port, err := envInt("GARDOMATIC_SMTP_PORT", 25)
+ 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"),
+ mail: mailer.Config{
+ Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
+ Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
+ Port: port,
+ Username: os.Getenv("GARDOMATIC_SMTP_USERNAME"),
+ Password: os.Getenv("GARDOMATIC_SMTP_PASSWORD"),
+ Sender: envString("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"),
+ FilePath: envString("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log"),
+ },
+ }, nil
+}
+
+func envString(name, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(name)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func envInt(name string, fallback int) (int, error) {
+ value := strings.TrimSpace(os.Getenv(name))
+ if value == "" {
+ return fallback, nil
+ }
+
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return 0, fmt.Errorf("%s must be an integer: %w", name, err)
+ }
+ return n, nil
+}
diff --git a/cmd/cli/doc.go b/cmd/cli/doc.go
new file mode 100644
index 0000000..03a6af3
--- /dev/null
+++ b/cmd/cli/doc.go
@@ -0,0 +1,2 @@
+// Package main provides the Gardomatic administration command-line tool.
+package main
diff --git a/cmd/cli/main.go b/cmd/cli/main.go
new file mode 100644
index 0000000..f0be839
--- /dev/null
+++ b/cmd/cli/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+)
+
+func main() {
+ app := newApplication(os.Stdin, os.Stdout, os.Stderr)
+ if err := app.run(context.Background(), os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/cli/store.go b/cmd/cli/store.go
new file mode 100644
index 0000000..318c8a4
--- /dev/null
+++ b/cmd/cli/store.go
@@ -0,0 +1,336 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "gardomatic.kleiax.de/internal/storage"
+ "github.com/lib/pq"
+)
+
+var (
+ errUserNotFound = errors.New("user not found")
+ errUserAlreadyActive = errors.New("user is already active")
+)
+
+type userView struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Activated bool `json:"activated"`
+ Role string `json:"role"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type gardenMemberView struct {
+ GardenID int `json:"garden_id"`
+ UserID int `json:"user_id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ JoinedAt time.Time `json:"joined_at"`
+}
+
+type createUserInput struct {
+ Name string
+ Email string
+ PasswordHash []byte
+ Activated bool
+ Role string
+ Invite bool
+ TokenTTL time.Duration
+}
+
+type createUserResult struct {
+ User userView
+ Token *auth.Token
+}
+
+type adminStore interface {
+ Ping(context.Context) error
+ CreateUser(context.Context, createUserInput) (createUserResult, error)
+ ListUsers(context.Context) ([]userView, error)
+ GetUser(context.Context, string) (userView, error)
+ SetActivated(context.Context, string, bool) (userView, error)
+ IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error)
+ ResetPassword(context.Context, string, []byte) (userView, error)
+ SetUserRole(context.Context, string, string) (userView, error)
+ AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
+ Close() error
+}
+
+type postgresStore struct {
+ db *sql.DB
+}
+
+func openPostgresStore(dsn string) (adminStore, error) {
+ if dsn == "" {
+ return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn")
+ }
+ db, err := sql.Open("postgres", dsn)
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(5)
+ db.SetMaxIdleConns(2)
+ db.SetConnMaxIdleTime(5 * time.Minute)
+ return &postgresStore{db: db}, nil
+}
+
+// Close releases the CLI database pool.
+func (s *postgresStore) Close() error { return s.db.Close() }
+
+// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout.
+func (s *postgresStore) Ping(ctx context.Context) error {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ return s.db.PingContext(ctx)
+}
+
+// CreateUser creates an account and optionally an invitation token atomically.
+func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return createUserResult{}, err
+ }
+ defer tx.Rollback()
+
+ var result createUserResult
+ err = tx.QueryRowContext(ctx, `
+ INSERT INTO users (name, email, password_hash, activated, application_role)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, name, email, activated, application_role, created_at, updated_at`,
+ input.Name, input.Email, input.PasswordHash, input.Activated, input.Role,
+ ).Scan(
+ &result.User.ID,
+ &result.User.Name,
+ &result.User.Email,
+ &result.User.Activated,
+ &result.User.Role,
+ &result.User.CreatedAt,
+ &result.User.UpdatedAt,
+ )
+ if err != nil {
+ return createUserResult{}, mapPostgresError(err)
+ }
+
+ if input.Invite {
+ token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation)
+ if _, err = tx.ExecContext(ctx, `
+ INSERT INTO tokens (hash, user_id, expiry, scope)
+ VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
+ return createUserResult{}, err
+ }
+ result.Token = &token
+ }
+
+ if err = tx.Commit(); err != nil {
+ return createUserResult{}, err
+ }
+ return result, nil
+}
+
+// ListUsers returns all non-deleted accounts for CLI administration.
+func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT id, name, email, activated, application_role, created_at, updated_at
+ FROM users
+ WHERE deleted_at IS NULL
+ ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ users := make([]userView, 0)
+ for rows.Next() {
+ var user userView
+ if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil {
+ return nil, err
+ }
+ users = append(users, user)
+ }
+ return users, rows.Err()
+}
+
+// GetUser returns a non-deleted account by email.
+func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ return getUser(ctx, s.db, email)
+}
+
+// SetActivated changes whether an account may authenticate.
+func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return userView{}, err
+ }
+ defer tx.Rollback()
+
+ var user userView
+ err = tx.QueryRowContext(ctx, `
+ UPDATE users
+ SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE email = $1 AND deleted_at IS NULL
+ RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated,
+ ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return userView{}, errUserNotFound
+ }
+ if err != nil {
+ return userView{}, err
+ }
+ if activated {
+ if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
+ return userView{}, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return userView{}, err
+ }
+ return user, nil
+}
+
+// IssueInvitation replaces an account's activation token.
+func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return userView{}, auth.Token{}, err
+ }
+ defer tx.Rollback()
+
+ user, err := getUser(ctx, tx, email)
+ if err != nil {
+ return userView{}, auth.Token{}, err
+ }
+ if user.Activated {
+ return userView{}, auth.Token{}, errUserAlreadyActive
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
+ return userView{}, auth.Token{}, err
+ }
+ token := auth.NewToken(user.ID, ttl, auth.ScopeActivation)
+ if _, err = tx.ExecContext(ctx, `
+ INSERT INTO tokens (hash, user_id, expiry, scope)
+ VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
+ return userView{}, auth.Token{}, err
+ }
+ if err = tx.Commit(); err != nil {
+ return userView{}, auth.Token{}, err
+ }
+ return user, token, nil
+}
+
+// ResetPassword replaces an account password hash and revokes authentication tokens.
+func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return userView{}, err
+ }
+ defer tx.Rollback()
+
+ var user userView
+ err = tx.QueryRowContext(ctx, `
+ UPDATE users
+ SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE email = $1 AND deleted_at IS NULL
+ RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash,
+ ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return userView{}, errUserNotFound
+ }
+ if err != nil {
+ return userView{}, err
+ }
+ if _, err = tx.ExecContext(ctx, `
+ DELETE FROM tokens
+ WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil {
+ return userView{}, err
+ }
+ if err = tx.Commit(); err != nil {
+ return userView{}, err
+ }
+ return user, nil
+}
+
+// SetUserRole changes an account's application role.
+func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ var user userView
+ err := s.db.QueryRowContext(ctx, `
+ UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1
+ WHERE email=$1 AND deleted_at IS NULL
+ RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role,
+ ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return userView{}, errUserNotFound
+ }
+ return user, err
+}
+
+// AddGardenMember adds or updates a user's membership in a garden.
+func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ var member gardenMemberView
+ err := s.db.QueryRowContext(ctx, `
+ INSERT INTO garden_members(garden_id,user_id,role)
+ SELECT $1,u.id,$3 FROM users u
+ WHERE u.email=$2 AND u.deleted_at IS NULL
+ AND EXISTS (SELECT 1 FROM gardens WHERE id=$1)
+ AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
+ RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role,
+ ).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return gardenMemberView{}, storage.ErrRecordNotFound
+ }
+ return member, mapPostgresError(err)
+}
+
+type dbQuerier interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
+
+func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) {
+ var user userView
+ err := db.QueryRowContext(ctx, `
+ SELECT id, name, email, activated, application_role, created_at, updated_at
+ FROM users
+ WHERE email = $1 AND deleted_at IS NULL`, email,
+ ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return userView{}, errUserNotFound
+ }
+ if err != nil {
+ return userView{}, err
+ }
+ return user, nil
+}
+
+func mapPostgresError(err error) error {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" {
+ return storage.ErrDuplicateEmail
+ }
+ return err
+}
diff --git a/cmd/web/config.go b/cmd/web/config.go
new file mode 100644
index 0000000..fa4cf47
--- /dev/null
+++ b/cmd/web/config.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ "gardomatic.kleiax.de/internal/platform/environment"
+ "gardomatic.kleiax.de/internal/web"
+)
+
+func configFromEnvironment() (web.Config, error) {
+ port, err := environment.Int("GARDOMATIC_WEB_PORT", 4040)
+ if err != nil {
+ return web.Config{}, err
+ }
+ cookieSecure, err := environment.Bool("GARDOMATIC_COOKIE_SECURE", false)
+ 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}
+ var errs []error
+ if cfg.Port < 1 || cfg.Port > 65535 {
+ errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 65535"))
+ }
+ parsed, parseErr := url.ParseRequestURI(cfg.APIBaseURL)
+ if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
+ errs = append(errs, fmt.Errorf("GARDOMATIC_API_BASE_URL must be an absolute HTTP URL"))
+ }
+ if cfg.Env == "production" && !cfg.CookieSecure {
+ errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
+ }
+ return cfg, errors.Join(errs...)
+}
diff --git a/cmd/web/config_test.go b/cmd/web/config_test.go
new file mode 100644
index 0000000..8f804ab
--- /dev/null
+++ b/cmd/web/config_test.go
@@ -0,0 +1,16 @@
+package main
+
+import "testing"
+
+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")
+ 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" {
+ t.Fatalf("unexpected config: %#v", cfg)
+ }
+}
diff --git a/cmd/web/doc.go b/cmd/web/doc.go
new file mode 100644
index 0000000..31596ae
--- /dev/null
+++ b/cmd/web/doc.go
@@ -0,0 +1,2 @@
+// Package main starts the Gardomatic server-rendered web application.
+package main
diff --git a/cmd/web/main.go b/cmd/web/main.go
new file mode 100644
index 0000000..99504f0
--- /dev/null
+++ b/cmd/web/main.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+ "log"
+
+ "gardomatic.kleiax.de/internal/web"
+)
+
+func main() {
+ cfg, err := configFromEnvironment()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ server, err := web.New(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ server.Run()
+}
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..4a25191
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,94 @@
+services:
+ postgres:
+ image: docker.io/library/postgres:16-alpine
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ ports:
+ - "${POSTGRES_PORT:-5432}:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 2s
+ timeout: 5s
+ retries: 15
+ volumes:
+ - gardomatic-postgres-data:/var/lib/postgresql/data
+
+ migrate:
+ image: docker.io/migrate/migrate:v4.19.1
+ command:
+ - -path=/migrations
+ - -database=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
+ - up
+ depends_on:
+ postgres:
+ condition: service_healthy
+ volumes:
+ - ./internal/storage/postgres/migrations:/migrations:ro
+ restart: "no"
+
+ api:
+ build:
+ context: .
+ target: api
+ environment:
+ GARDOMATIC_API_PORT: 4000
+ GARDOMATIC_DB_DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
+ GARDOMATIC_DB_MAX_OPEN_CONNS: ${GARDOMATIC_DB_MAX_OPEN_CONNS:-25}
+ GARDOMATIC_DB_MAX_IDLE_CONNS: ${GARDOMATIC_DB_MAX_IDLE_CONNS:-25}
+ GARDOMATIC_DB_MAX_IDLE_TIME: ${GARDOMATIC_DB_MAX_IDLE_TIME:-15m}
+ GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development}
+ GARDOMATIC_WEB_BASE_URL: ${GARDOMATIC_WEB_BASE_URL:-http://localhost:4040}
+ GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session}
+ GARDOMATIC_SESSION_LIFETIME: ${GARDOMATIC_SESSION_LIFETIME:-12h}
+ GARDOMATIC_SESSION_IDLE_TIMEOUT: ${GARDOMATIC_SESSION_IDLE_TIMEOUT:-30m}
+ GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false}
+ GARDOMATIC_RATE_LIMIT_ENABLED: ${GARDOMATIC_RATE_LIMIT_ENABLED:-true}
+ GARDOMATIC_RATE_LIMIT_RPS: ${GARDOMATIC_RATE_LIMIT_RPS:-10}
+ GARDOMATIC_RATE_LIMIT_BURST: ${GARDOMATIC_RATE_LIMIT_BURST:-40}
+ GARDOMATIC_CORS_TRUSTED_ORIGINS: ${GARDOMATIC_CORS_TRUSTED_ORIGINS:-http://localhost:4040}
+ GARDOMATIC_SMTP_MODE: ${GARDOMATIC_SMTP_MODE:-file}
+ GARDOMATIC_SMTP_HOST: ${GARDOMATIC_SMTP_HOST:-}
+ GARDOMATIC_SMTP_PORT: ${GARDOMATIC_SMTP_PORT:-25}
+ GARDOMATIC_SMTP_USERNAME: ${GARDOMATIC_SMTP_USERNAME:-}
+ GARDOMATIC_SMTP_PASSWORD: ${GARDOMATIC_SMTP_PASSWORD:-}
+ GARDOMATIC_SMTP_SENDER: ${GARDOMATIC_SMTP_SENDER:-gardomatic@localhost}
+ GARDOMATIC_SMTP_FILE_PATH: ${GARDOMATIC_SMTP_FILE_PATH:-/tmp/gardomatic-mails.log}
+ ports:
+ - "${API_PORT:-4000}:4000"
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4000/v1/healthcheck"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+ web:
+ build:
+ context: .
+ target: web
+ environment:
+ GARDOMATIC_API_BASE_URL: http://api:4000
+ GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development}
+ GARDOMATIC_WEB_PORT: 4040
+ GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session}
+ GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false}
+ ports:
+ - "${WEB_PORT:-4040}:4040"
+ depends_on:
+ api:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4040/ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+volumes:
+ gardomatic-postgres-data:
+ driver: local
diff --git a/config.mk.example b/config.mk.example
new file mode 100644
index 0000000..f3c40e3
--- /dev/null
+++ b/config.mk.example
@@ -0,0 +1,17 @@
+# Copy this file to config.mk. It contains non-secret Make configuration only.
+
+# Remote SSH administrator supplied by the hoster or LXC configuration. Use
+# root, ubuntu, or another account with passwordless sudo. This is deliberately
+# not the unprivileged gardomatic service account.
+PRODUCTION_HOST =
+PRODUCTION_SSH_USER = root
+PRODUCTION_SSH_PORT = 22
+
+# Optional private key path on the local workstation. Leave empty to use the
+# SSH agent and normal ~/.ssh/config resolution. Paths must not contain spaces.
+PRODUCTION_SSH_IDENTITY_FILE =
+
+# Target operating system and architecture. Supported provisioning architectures
+# are amd64 and arm64.
+PRODUCTION_GOOS = linux
+PRODUCTION_GOARCH = amd64
diff --git a/doc/cli.md b/doc/cli.md
new file mode 100644
index 0000000..e62f4fd
--- /dev/null
+++ b/doc/cli.md
@@ -0,0 +1,87 @@
+# Gardomatic administration CLI
+
+The CLI connects directly to PostgreSQL and is implemented in `cmd/cli`.
+
+## Configuration
+
+`GARDOMATIC_DB_DSN` is required for every database command. The following
+variables are optional:
+
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `GARDOMATIC_ENV` | `development` | Enables confirmations for risky production operations |
+| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | Base URL for activation links |
+| `GARDOMATIC_SMTP_MODE` | `file` | Mail delivery mode (`file` or `smtp`) |
+| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname |
+| `GARDOMATIC_SMTP_PORT` | `25` | SMTP server port |
+| `GARDOMATIC_SMTP_USERNAME` | empty | SMTP username |
+| `GARDOMATIC_SMTP_PASSWORD` | empty | SMTP password |
+| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Sender address |
+| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Development mail output |
+
+Global flags can override the DSN, environment, and public URL. Global flags
+must appear before the command. Use `--json` for machine-readable output and
+`--yes` to confirm an explicitly configured production operation.
+
+## Examples
+
+Create an invited user and print a generated initial password:
+
+```sh
+go run ./cmd/cli users create \
+ --name "Alice Example" \
+ --email alice@example.com \
+ --invite \
+ --generate-password
+```
+
+Create the initial active application administrator atomically:
+
+```sh
+go run ./cmd/cli users create \
+ --name "Initial Admin" \
+ --email admin@example.com \
+ --role application:admin \
+ --active \
+ --generate-password
+```
+
+Create an active development user and read the password without exposing it in
+the process list:
+
+```sh
+printf '%s\n' 'correct horse battery staple' | \
+ go run ./cmd/cli users create \
+ --name "Development User" \
+ --email dev@example.com \
+ --active \
+ --password-stdin
+```
+
+Without `--password-stdin` or `--generate-password`, the CLI securely prompts
+for the password twice. An invitation is printed by default; add `--send-email`
+to deliver it using the configured mail backend.
+
+Other common operations:
+
+```sh
+go run ./cmd/cli users list
+go run ./cmd/cli users show --email alice@example.com
+go run ./cmd/cli users invite --email alice@example.com --send-email
+go run ./cmd/cli users activate --email alice@example.com
+go run ./cmd/cli users deactivate --email alice@example.com
+go run ./cmd/cli users reset-password --email alice@example.com --generate-password
+go run ./cmd/cli users set-role --email alice@example.com --role application:admin
+go run ./cmd/cli gardens add-user --garden-id 3 --email alice@example.com --role admin
+go run ./cmd/cli db ping
+```
+
+`users create` requires exactly one of `--active` and `--invite` and accepts
+`application:user` or `application:admin` as `--role`. User creation, its role,
+and its activation token are committed in one database transaction. Password
+reset invalidates existing bearer and password reset tokens. The CLI never
+accepts passwords as command-line arguments.
+
+Application roles (`application:user`, `application:admin`) are independent of
+garden roles (`owner`, `admin`, `member`, `viewer`, `worker`). `gardens add-user`
+uses `member` when `--role` is omitted.
diff --git a/doc/ideen.md b/doc/ideen.md
new file mode 100644
index 0000000..471b05b
--- /dev/null
+++ b/doc/ideen.md
@@ -0,0 +1,28 @@
+# Muss
+- Während man in den Admineinstellungen ist, soll der aktuelle Garten beibehalten werden
+
+# Demnächst und konkret
+- Garten bearbeiten soll auch mit dem Menü Links ausgestattet werden
+- Link auf git und kleiax.de
+- Seite mit Informationen zu Server Version etc Serverzeit
+- Die Instanzrolle brauchen noch eine Berechtigung, ob Gärten angelegt werden dürfen
+
+# Vielleicht
+- Detailansicht und Bearbeitenansicht trennen?
+- Impressum
+
+# Unklar
+- pickieren? zwischen aufgabe und in stammdaten entscheiden
+
+# Später
+- Import von Pflanzen-Details z.B Pflanzmich.de und Naturadatenbank
+- Bewässerung direkt an die Orte binden
+ - Auflösen welche Pflanzen dadurch automatisch bewässert werden
+ - Zapfstellen könnten auch ein Ort sein
+- Datenschutz seite
+
+# Refactoring
+- Alle Migrationen für die v1.0 zusammenfassen
+- Module prüfen auf logisch Trennung, welche Module wären wiederverwendbar? Mailer ist ein schwieriger Fall, weil spezifische templates drin sind.
+- neues Repo für v1.0
+- gesamtes projekt dokumentieren
\ No newline at end of file
diff --git a/doc/notes.md b/doc/notes.md
new file mode 100644
index 0000000..846e8f2
--- /dev/null
+++ b/doc/notes.md
@@ -0,0 +1,5 @@
+#Migrate
+migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable up
+migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable force 1
+authentication -> Wer ist es?
+authorization -> Darf er das?
diff --git a/doc/planung.md b/doc/planung.md
new file mode 100644
index 0000000..ff1428e
--- /dev/null
+++ b/doc/planung.md
@@ -0,0 +1,121 @@
+# Gardomatic — Projektbeschreibung und Fahrplan
+
+*Planungsstand: 01.09.2026*
+
+## Projektbeschreibung
+
+Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur Organisation von
+Gärten. Nutzer verwalten Gärten, Arten und Sorten, konkrete Pflanzen, Pflanzorte und
+anstehende Arbeiten. Artenbezogene Aufgabenvorlagen erzeugen passend zum Kalender,
+zum Pflanzdatum oder zur letzten Erledigung automatisch konkrete Aufgaben. Ein Garten
+bildet dabei einen abgeschlossenen Daten- und Berechtigungsraum.
+
+Die Anwendung besteht aus einer JSON-API und einem serverseitig gerenderten
+Web-Client in Go. PostgreSQL ist die einzige unterstützte Datenbank. Das Frontend
+ist mobile-first, funktioniert in den wesentlichen Abläufen ohne JavaScript und
+nutzt htmx für komfortable Teilaktualisierungen. Die installierbare PWA bietet eine
+Offline-App-Shell; die eigentlichen Gartendaten bleiben serverseitig geführt.
+
+Gardomatic richtet sich an Einzelpersonen und kleine Gruppen, die einen oder mehrere
+Gärten gemeinsam pflegen. Zeitfenster statt starrer Einzeltermine bilden
+gärtnerische Arbeiten realistisch ab. Rollen, Einladungen und konsequente
+Gartenisolierung ermöglichen eine sichere Zusammenarbeit.
+
+## Verbindliche Leitplanken
+
+- Go mit `internal/`-Layout und getrennten Binaries für API, Web und Administration.
+- PostgreSQL 16 mit `database/sql`, `lib/pq` und versionierten SQL-Migrationen.
+- Garten-ID im Pfad (`/v1/gardens/{gardenID}/...` und `/g/{gardenID}/...`).
+- Fremde Garten-IDs liefern 404; unzulässige Aktionen innerhalb eines sichtbaren
+ Gartens liefern 403.
+- Gartenrollen sind `owner`, `admin`, `member` und `viewer`.
+- Artenstammdaten und konkrete Pflanzen bleiben getrennt; Pflanzen dürfen ohne Art
+ angelegt werden.
+- Aufgaben verwenden Fälligkeitsfenster. Wiederkehrende Zeiträume dürfen den
+ Jahreswechsel überspannen.
+- Aus Vorlagen erzeugte Aufgaben bleiben idempotent. Ein separater Scheduler wird
+ erst bei nachgewiesenem Bedarf eingeführt.
+- Authentifizierung und Autorisierung liegen in der API. Das Web reicht nur das
+ Session-Cookie über einen request-spezifischen API-Client weiter.
+- Neue Facharbeit wird als vollständiger Vertical Slice aus Migration, Storage,
+ API, Client, Web und Tests umgesetzt.
+
+## Weiterer Fahrplan
+
+### 1. Kalenderbereitstellung über CalDAV
+
+**Ziel:** Anstehende Aufgaben können in vorhandenen Kalender- und
+Aufgabenanwendungen abonniert werden.
+
+Vorgeschlagene Umsetzung:
+
+1. Zuerst einen technischen Prototyp mit einer nur lesbaren Collection pro Nutzer
+ und Garten erstellen. Die Collection erhält stabile Ressourcen-IDs, ETags und
+ separat widerrufbare Zugangsdaten.
+2. Aufgaben als `VTODO` abbilden: Titel, Beschreibung, Fälligkeitsfenster,
+ Priorität, Status sowie Pflanzen- und Ortsbezug. Gartenrollen begrenzen auch hier
+ die sichtbaren Daten.
+3. Optional eine schreibgeschützte `VEVENT`-Ansicht für Clients ergänzen, die
+ `VTODO` nicht sinnvoll darstellen. Mehrtägige Fälligkeitsfenster bleiben dabei
+ als Zeiträume erkennbar.
+4. Einrichtung und Widerruf im Benutzerkonto ergänzen und mindestens mit DAVx⁵,
+ Apple Kalender/Erinnerungen und Thunderbird prüfen.
+5. Schreibzugriffe bewusst zurückstellen, bis Konfliktauflösung, Optimistic
+ Locking, Zeitzonen und die Semantik externer Erledigungen festgelegt sind.
+
+**Abnahmekriterien:** Eine gartenbezogene, nur lesbare Collection lässt sich in
+mindestens zwei unterstützten Clients einrichten; Änderungen erscheinen nach der
+Synchronisation; widerrufene Zugänge und Zugriffe auf fremde Gärten funktionieren
+nicht.
+
+### 2. Erstes Erweiterungsmodul: Bewässerung
+
+**Ziel:** Den modularen Ausbau mit einem klar abgegrenzten Anwendungsfall erproben.
+
+Vorgeschlagener Umfang:
+
+1. Bewässerungszonen und deren Zuordnung zu vorhandenen Orten modellieren.
+2. Manuelle Laufzeiten sowie einfache, ausführbare Zeitprogramme bereitstellen.
+3. Tabellen mit `mod_irrigation_` benennen und Routen, Migrationen und
+ Berechtigungsprüfungen vom Kern abgrenzen.
+4. Maximale Laufzeit, konkurrierende Programme und Verhalten bei fehlender
+ Geräteverbindung definieren, bevor reale Ventile angesteuert werden.
+5. Hardware-Anbindung und Wetterautomatik erst nach einer nutzbaren manuellen
+ Planung auswählen. Ein allgemeines Modul-Interface erst einführen, wenn ein
+ zweites Modul tatsächlich gemeinsame Hooks benötigt.
+
+**Abnahmekriterien:** Das Modul kann deaktiviert werden, ohne Kernfunktionen zu
+beeinträchtigen; Planung und manuelle Ausführung sind nutzbar; Sicherheitsgrenzen
+sind automatisiert getestet.
+
+### 3. Aufgabenhistorie und Auswertung
+
+**Ziel:** Wiederkehrende Gartenarbeit später auswertbar machen, ohne den aktuellen
+Aufgabenablauf unnötig zu verkomplizieren.
+
+Vorgeschlagene Umsetzung:
+
+1. Vorab entscheiden, welche Fragen beantwortet werden sollen, beispielsweise
+ Erledigungen pro Zeitraum, Pflanze oder Ort.
+2. Erst danach ein append-only Ereignismodell für Erledigen, Wiederöffnen und
+ Terminänderungen entwerfen.
+3. Einen kompakten Export und wenige zielgerichtete Auswertungen vor komplexen
+ Dashboards priorisieren.
+
+**Abnahmekriterien:** Historische Daten verändern den aktuellen Aufgabenstatus nicht
+und bleiben gartenisoliert; die gewählten Auswertungen sind durch reale
+Nutzerfragen begründet.
+
+## Empfohlene Reihenfolge
+
+1. Als nächstes den lesenden CalDAV-Prototypen umsetzen und früh mit realen Clients
+ testen. Das reduziert das größte technische Kompatibilitätsrisiko.
+2. Danach den CalDAV-Zugang im Benutzerkonto produktionsreif machen und
+ dokumentieren.
+3. Anschließend das Bewässerungsmodul zunächst ohne Hardwareintegration liefern.
+4. Aufgabenhistorie und Reporting erst beginnen, wenn konkrete Auswertungsfragen
+ gesammelt wurden.
+
+Bidirektionales CalDAV, herstellerspezifische Bewässerungshardware und eine
+allgemeine Plugin-Architektur sind ausdrücklich keine Bestandteile der jeweils
+ersten Ausbaustufe.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b060461
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,32 @@
+module gardomatic.kleiax.de
+
+go 1.26.0
+
+require (
+ github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de
+ github.com/alexedwards/scs/v2 v2.9.0
+ github.com/go-playground/form/v4 v4.3.0
+ github.com/julienschmidt/httprouter v1.3.0
+ github.com/justinas/alice v1.2.0
+ github.com/justinas/nosurf v1.2.0
+ github.com/lib/pq v1.12.3
+ github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
+ github.com/wneessen/go-mail v0.8.1
+ github.com/yuin/goldmark v1.8.5
+ golang.org/x/crypto v0.55.0
+ golang.org/x/term v0.45.0
+ golang.org/x/time v0.15.0
+)
+
+require (
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
+ golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
+ golang.org/x/mod v0.38.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ golang.org/x/tools v0.48.0 // indirect
+ honnef.co/go/tools v0.8.1 // indirect
+)
+
+tool honnef.co/go/tools/cmd/staticcheck
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f757092
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,49 @@
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de h1:LDrMkjj4OCCQsq9SvIPQV1l3leMxqXZTCTxDFwMrqTE=
+github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M=
+github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
+github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk=
+github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
+github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
+github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
+github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
+github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
+github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
+github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
+github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
+github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
+github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
+golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
+honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
diff --git a/internal/api/account.go b/internal/api/account.go
new file mode 100644
index 0000000..21a4d66
--- /dev/null
+++ b/internal/api/account.go
@@ -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)
+ }
+}
diff --git a/internal/api/admin.go b/internal/api/admin.go
new file mode 100644
index 0000000..690e0a9
--- /dev/null
+++ b/internal/api/admin.go
@@ -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)
+}
diff --git a/internal/api/admin_environment.go b/internal/api/admin_environment.go
new file mode 100644
index 0000000..04e9a33
--- /dev/null
+++ b/internal/api/admin_environment.go
@@ -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)
+ }
+}
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
new file mode 100644
index 0000000..89e9d42
--- /dev/null
+++ b/internal/api/admin_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
new file mode 100644
index 0000000..f8374fc
--- /dev/null
+++ b/internal/api/api.go
@@ -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
+}
diff --git a/internal/api/application_settings.go b/internal/api/application_settings.go
new file mode 100644
index 0000000..4b63575
--- /dev/null
+++ b/internal/api/application_settings.go
@@ -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
+}
diff --git a/internal/api/application_settings_test.go b/internal/api/application_settings_test.go
new file mode 100644
index 0000000..26f43b9
--- /dev/null
+++ b/internal/api/application_settings_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/api/auth.go b/internal/api/auth.go
new file mode 100644
index 0000000..778f64e
--- /dev/null
+++ b/internal/api/auth.go
@@ -0,0 +1 @@
+package api
diff --git a/internal/api/care_instructions.go b/internal/api/care_instructions.go
new file mode 100644
index 0000000..3ec13ce
--- /dev/null
+++ b/internal/api/care_instructions.go
@@ -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
+}
diff --git a/internal/api/context.go b/internal/api/context.go
new file mode 100644
index 0000000..d2033a6
--- /dev/null
+++ b/internal/api/context.go
@@ -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
+}
diff --git a/internal/api/doc.go b/internal/api/doc.go
new file mode 100644
index 0000000..6c6c354
--- /dev/null
+++ b/internal/api/doc.go
@@ -0,0 +1,3 @@
+// Package api implements the Gardomatic HTTP API, including authentication,
+// authorization middleware, request validation, and JSON response handling.
+package api
diff --git a/internal/api/errors.go b/internal/api/errors.go
new file mode 100644
index 0000000..fd94710
--- /dev/null
+++ b/internal/api/errors.go
@@ -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)
+}
diff --git a/internal/api/garden_members.go b/internal/api/garden_members.go
new file mode 100644
index 0000000..d673c0e
--- /dev/null
+++ b/internal/api/garden_members.go
@@ -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)
+ }
+}
diff --git a/internal/api/gardens.go b/internal/api/gardens.go
new file mode 100644
index 0000000..0786e80
--- /dev/null
+++ b/internal/api/gardens.go
@@ -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)
+ }
+}
diff --git a/internal/api/gardens_test.go b/internal/api/gardens_test.go
new file mode 100644
index 0000000..052e9a2
--- /dev/null
+++ b/internal/api/gardens_test.go
@@ -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())
+ }
+}
diff --git a/internal/api/healthcheck.go b/internal/api/healthcheck.go
new file mode 100644
index 0000000..3ff4072
--- /dev/null
+++ b/internal/api/healthcheck.go
@@ -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)
+ }
+}
diff --git a/internal/api/healthcheck_test.go b/internal/api/healthcheck_test.go
new file mode 100644
index 0000000..3eb1ddf
--- /dev/null
+++ b/internal/api/healthcheck_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/helpers.go b/internal/api/helpers.go
new file mode 100644
index 0000000..b1eebf2
--- /dev/null
+++ b/internal/api/helpers.go
@@ -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()
+ })
+}
diff --git a/internal/api/images.go b/internal/api/images.go
new file mode 100644
index 0000000..c547a0e
--- /dev/null
+++ b/internal/api/images.go
@@ -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)
+}
diff --git a/internal/api/journal.go b/internal/api/journal.go
new file mode 100644
index 0000000..7c142a7
--- /dev/null
+++ b/internal/api/journal.go
@@ -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
+}
diff --git a/internal/api/journal_test.go b/internal/api/journal_test.go
new file mode 100644
index 0000000..34cfc11
--- /dev/null
+++ b/internal/api/journal_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/locations.go b/internal/api/locations.go
new file mode 100644
index 0000000..74a0266
--- /dev/null
+++ b/internal/api/locations.go
@@ -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
+}
diff --git a/internal/api/locations_test.go b/internal/api/locations_test.go
new file mode 100644
index 0000000..eb0897f
--- /dev/null
+++ b/internal/api/locations_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..7b3f975
--- /dev/null
+++ b/internal/api/middleware.go
@@ -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)
+ })
+}
diff --git a/internal/api/permissions_test.go b/internal/api/permissions_test.go
new file mode 100644
index 0000000..589766b
--- /dev/null
+++ b/internal/api/permissions_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/api/plant_locations.go b/internal/api/plant_locations.go
new file mode 100644
index 0000000..c7a38c1
--- /dev/null
+++ b/internal/api/plant_locations.go
@@ -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
+}
diff --git a/internal/api/plants.go b/internal/api/plants.go
new file mode 100644
index 0000000..8f05b68
--- /dev/null
+++ b/internal/api/plants.go
@@ -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
+}
diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go
new file mode 100644
index 0000000..bab2851
--- /dev/null
+++ b/internal/api/resources_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/roles.go b/internal/api/roles.go
new file mode 100644
index 0000000..f29e9f3
--- /dev/null
+++ b/internal/api/roles.go
@@ -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)
+}
diff --git a/internal/api/routes.go b/internal/api/routes.go
new file mode 100644
index 0000000..c728157
--- /dev/null
+++ b/internal/api/routes.go
@@ -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)))
+}
diff --git a/internal/api/season_templates.go b/internal/api/season_templates.go
new file mode 100644
index 0000000..3b01b03
--- /dev/null
+++ b/internal/api/season_templates.go
@@ -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)
+}
diff --git a/internal/api/season_templates_test.go b/internal/api/season_templates_test.go
new file mode 100644
index 0000000..77e2de7
--- /dev/null
+++ b/internal/api/season_templates_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
new file mode 100644
index 0000000..5cb0a4e
--- /dev/null
+++ b/internal/api/server.go
@@ -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
+}
diff --git a/internal/api/sessions.go b/internal/api/sessions.go
new file mode 100644
index 0000000..23579ea
--- /dev/null
+++ b/internal/api/sessions.go
@@ -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)
+}
diff --git a/internal/api/sessions_test.go b/internal/api/sessions_test.go
new file mode 100644
index 0000000..6b69909
--- /dev/null
+++ b/internal/api/sessions_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/species.go b/internal/api/species.go
new file mode 100644
index 0000000..7ca5f94
--- /dev/null
+++ b/internal/api/species.go
@@ -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)
+ }
+}
diff --git a/internal/api/species_categories.go b/internal/api/species_categories.go
new file mode 100644
index 0000000..7a9844a
--- /dev/null
+++ b/internal/api/species_categories.go
@@ -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
+}
diff --git a/internal/api/species_categories_test.go b/internal/api/species_categories_test.go
new file mode 100644
index 0000000..cd97083
--- /dev/null
+++ b/internal/api/species_categories_test.go
@@ -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())
+ }
+}
diff --git a/internal/api/species_task_templates.go b/internal/api/species_task_templates.go
new file mode 100644
index 0000000..cde87a8
--- /dev/null
+++ b/internal/api/species_task_templates.go
@@ -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
+}
diff --git a/internal/api/species_task_templates_test.go b/internal/api/species_task_templates_test.go
new file mode 100644
index 0000000..448cbf0
--- /dev/null
+++ b/internal/api/species_task_templates_test.go
@@ -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())
+ }
+}
diff --git a/internal/api/tags.go b/internal/api/tags.go
new file mode 100644
index 0000000..9222944
--- /dev/null
+++ b/internal/api/tags.go
@@ -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)
+}
diff --git a/internal/api/task_generator.go b/internal/api/task_generator.go
new file mode 100644
index 0000000..07d195e
--- /dev/null
+++ b/internal/api/task_generator.go
@@ -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()
+}
diff --git a/internal/api/task_generator_test.go b/internal/api/task_generator_test.go
new file mode 100644
index 0000000..0d34d11
--- /dev/null
+++ b/internal/api/task_generator_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/api/task_priorities.go b/internal/api/task_priorities.go
new file mode 100644
index 0000000..cfa92c2
--- /dev/null
+++ b/internal/api/task_priorities.go
@@ -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
+}
diff --git a/internal/api/task_template_opt_outs.go b/internal/api/task_template_opt_outs.go
new file mode 100644
index 0000000..03aefc6
--- /dev/null
+++ b/internal/api/task_template_opt_outs.go
@@ -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)
+}
diff --git a/internal/api/tasks.go b/internal/api/tasks.go
new file mode 100644
index 0000000..80cabda
--- /dev/null
+++ b/internal/api/tasks.go
@@ -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
+}
diff --git a/internal/api/tasks_test.go b/internal/api/tasks_test.go
new file mode 100644
index 0000000..97368de
--- /dev/null
+++ b/internal/api/tasks_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/tokens.go b/internal/api/tokens.go
new file mode 100644
index 0000000..a55b568
--- /dev/null
+++ b/internal/api/tokens.go
@@ -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)
+ }
+}
diff --git a/internal/api/users.go b/internal/api/users.go
new file mode 100644
index 0000000..bbadfd0
--- /dev/null
+++ b/internal/api/users.go
@@ -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)
+ }
+}
diff --git a/internal/auth/doc.go b/internal/auth/doc.go
new file mode 100644
index 0000000..8c0136c
--- /dev/null
+++ b/internal/auth/doc.go
@@ -0,0 +1,3 @@
+// Package auth provides password hashing and secure token primitives used by
+// Gardomatic authentication flows.
+package auth
diff --git a/internal/auth/password.go b/internal/auth/password.go
new file mode 100644
index 0000000..6a29336
--- /dev/null
+++ b/internal/auth/password.go
@@ -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")
+}
diff --git a/internal/auth/token.go b/internal/auth/token.go
new file mode 100644
index 0000000..48d6d83
--- /dev/null
+++ b/internal/auth/token.go
@@ -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")
+}
diff --git a/internal/daterange/daterange.go b/internal/daterange/daterange.go
new file mode 100644
index 0000000..d492e94
--- /dev/null
+++ b/internal/daterange/daterange.go
@@ -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
+}
diff --git a/internal/daterange/daterange_test.go b/internal/daterange/daterange_test.go
new file mode 100644
index 0000000..788f0ca
--- /dev/null
+++ b/internal/daterange/daterange_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/mailer/doc.go b/internal/mailer/doc.go
new file mode 100644
index 0000000..ed56af4
--- /dev/null
+++ b/internal/mailer/doc.go
@@ -0,0 +1,3 @@
+// Package mailer renders and delivers Gardomatic transactional email through
+// SMTP or an append-only development file.
+package mailer
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
new file mode 100644
index 0000000..58cf084
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -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
+}
diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go
new file mode 100644
index 0000000..cf5c4db
--- /dev/null
+++ b/internal/mailer/mailer_test.go
@@ -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")
+ }
+}
diff --git a/internal/mailer/templates/email_change.tmpl b/internal/mailer/templates/email_change.tmpl
new file mode 100644
index 0000000..9756789
--- /dev/null
+++ b/internal/mailer/templates/email_change.tmpl
@@ -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"}}Bestätige deine neue E-Mail-Adresse:
E-Mail-Adresse bestätigen
{{end}}
diff --git a/internal/mailer/templates/garden_invite.tmpl b/internal/mailer/templates/garden_invite.tmpl
new file mode 100644
index 0000000..2233aa1
--- /dev/null
+++ b/internal/mailer/templates/garden_invite.tmpl
@@ -0,0 +1,3 @@
+{{define "subject"}}Einladung zu Gardomatic{{end}}
+{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}}
+{{define "htmlBody"}}Du wurdest zu einem Garten eingeladen.
Einladung annehmen
{{end}}
diff --git a/internal/mailer/templates/test_mail.tmpl b/internal/mailer/templates/test_mail.tmpl
new file mode 100644
index 0000000..a6123c9
--- /dev/null
+++ b/internal/mailer/templates/test_mail.tmpl
@@ -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"}}
+
+
+
+
+
+
+
+ Hallo,
+ diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.
+ Viele Grüße Gardomatic
+
+
+{{end}}
diff --git a/internal/mailer/templates/token_activation.tmpl b/internal/mailer/templates/token_activation.tmpl
new file mode 100644
index 0000000..9c897f7
--- /dev/null
+++ b/internal/mailer/templates/token_activation.tmpl
@@ -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"}}
+
+
+
+
+
+
+
+ Hi,
+ {{if .activationURL}}
+ Activate your Gardomatic account
+ 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}}
diff --git a/internal/mailer/templates/token_password_reset.tmpl b/internal/mailer/templates/token_password_reset.tmpl
new file mode 100644
index 0000000..685c32a
--- /dev/null
+++ b/internal/mailer/templates/token_password_reset.tmpl
@@ -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"}}
+
+
+
+
+
+
+
+ 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}}
diff --git a/internal/mailer/templates/user_invitation.tmpl b/internal/mailer/templates/user_invitation.tmpl
new file mode 100644
index 0000000..accc1f7
--- /dev/null
+++ b/internal/mailer/templates/user_invitation.tmpl
@@ -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"}}
+
+
+
+
+
+
+
+ Hallo {{.name}},
+ du wurdest zu Gardomatic eingeladen.
+ Account aktivieren und Passwort festlegen
+ Der Link ist drei Tage lang gültig.
+ Viele Grüße Gardomatic
+
+
+{{end}}
diff --git a/internal/mailer/templates/user_welcome.tmpl b/internal/mailer/templates/user_welcome.tmpl
new file mode 100644
index 0000000..ef68302
--- /dev/null
+++ b/internal/mailer/templates/user_welcome.tmpl
@@ -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"}}
+
+
+
+
+
+
+
+
+
+ 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 Gardomatic account
+ 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}}
diff --git a/internal/platform/environment/environment.go b/internal/platform/environment/environment.go
new file mode 100644
index 0000000..23ab36d
--- /dev/null
+++ b/internal/platform/environment/environment.go
@@ -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
+}
diff --git a/internal/platform/environment/environment_test.go b/internal/platform/environment/environment_test.go
new file mode 100644
index 0000000..f7710c6
--- /dev/null
+++ b/internal/platform/environment/environment_test.go
@@ -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")
+ }
+}
diff --git a/internal/platform/validate/doc.go b/internal/platform/validate/doc.go
new file mode 100644
index 0000000..3059083
--- /dev/null
+++ b/internal/platform/validate/doc.go
@@ -0,0 +1,3 @@
+// Package validate contains reusable validation helpers and an error collector
+// for API and form inputs.
+package validate
diff --git a/internal/platform/validate/helper.go b/internal/platform/validate/helper.go
new file mode 100644
index 0000000..36bbba0
--- /dev/null
+++ b/internal/platform/validate/helper.go
@@ -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
+}
diff --git a/internal/platform/validate/validator.go b/internal/platform/validate/validator.go
new file mode 100644
index 0000000..4e7cefa
--- /dev/null
+++ b/internal/platform/validate/validator.go
@@ -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)
+ }
+}
diff --git a/internal/storage/application_settings.go b/internal/storage/application_settings.go
new file mode 100644
index 0000000..baa05c7
--- /dev/null
+++ b/internal/storage/application_settings.go
@@ -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")
+ }
+}
diff --git a/internal/storage/care_instructions.go b/internal/storage/care_instructions.go
new file mode 100644
index 0000000..a194a95
--- /dev/null
+++ b/internal/storage/care_instructions.go
@@ -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")
+}
diff --git a/internal/storage/doc.go b/internal/storage/doc.go
new file mode 100644
index 0000000..be772c9
--- /dev/null
+++ b/internal/storage/doc.go
@@ -0,0 +1,3 @@
+// Package storage defines Gardomatic persistence models and the interfaces
+// implemented by database-specific adapters.
+package storage
diff --git a/internal/storage/errors.go b/internal/storage/errors.go
new file mode 100644
index 0000000..65e0bae
--- /dev/null
+++ b/internal/storage/errors.go
@@ -0,0 +1,10 @@
+package storage
+
+import (
+ "errors"
+)
+
+var (
+ // ErrDuplicateEmail indicates that a user email is already registered.
+ ErrDuplicateEmail = errors.New("models: duplicate email")
+)
diff --git a/internal/storage/filters.go b/internal/storage/filters.go
new file mode 100644
index 0000000..686c974
--- /dev/null
+++ b/internal/storage/filters.go
@@ -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,
+ }
+}
diff --git a/internal/storage/garden_invites.go b/internal/storage/garden_invites.go
new file mode 100644
index 0000000..c4e52f5
--- /dev/null
+++ b/internal/storage/garden_invites.go
@@ -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"`
+}
diff --git a/internal/storage/garden_members.go b/internal/storage/garden_members.go
new file mode 100644
index 0000000..9655d92
--- /dev/null
+++ b/internal/storage/garden_members.go
@@ -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
+}
diff --git a/internal/storage/gardens.go b/internal/storage/gardens.go
new file mode 100644
index 0000000..e6d5f0f
--- /dev/null
+++ b/internal/storage/gardens.go
@@ -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")
+}
diff --git a/internal/storage/images.go b/internal/storage/images.go
new file mode 100644
index 0000000..9fd6a94
--- /dev/null
+++ b/internal/storage/images.go
@@ -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
+}
diff --git a/internal/storage/journal.go b/internal/storage/journal.go
new file mode 100644
index 0000000..d32e332
--- /dev/null
+++ b/internal/storage/journal.go
@@ -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")
+}
diff --git a/internal/storage/journal_test.go b/internal/storage/journal_test.go
new file mode 100644
index 0000000..d801383
--- /dev/null
+++ b/internal/storage/journal_test.go
@@ -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)
+ }
+}
diff --git a/internal/storage/locations.go b/internal/storage/locations.go
new file mode 100644
index 0000000..41d3697
--- /dev/null
+++ b/internal/storage/locations.go
@@ -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"`
+}
diff --git a/internal/storage/models.go b/internal/storage/models.go
new file mode 100644
index 0000000..a79cd1d
--- /dev/null
+++ b/internal/storage/models.go
@@ -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
+}
diff --git a/internal/storage/plant_locations.go b/internal/storage/plant_locations.go
new file mode 100644
index 0000000..3d5bcf2
--- /dev/null
+++ b/internal/storage/plant_locations.go
@@ -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")
+ }
+}
diff --git a/internal/storage/plants.go b/internal/storage/plants.go
new file mode 100644
index 0000000..4ec12f7
--- /dev/null
+++ b/internal/storage/plants.go
@@ -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")
+ }
+}
diff --git a/internal/storage/postgres/application_settings.go b/internal/storage/postgres/application_settings.go
new file mode 100644
index 0000000..b80d546
--- /dev/null
+++ b/internal/storage/postgres/application_settings.go
@@ -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
+}
diff --git a/internal/storage/postgres/application_settings_integration_test.go b/internal/storage/postgres/application_settings_integration_test.go
new file mode 100644
index 0000000..07d7364
--- /dev/null
+++ b/internal/storage/postgres/application_settings_integration_test.go
@@ -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)
+ }
+}
diff --git a/internal/storage/postgres/care_instructions.go b/internal/storage/postgres/care_instructions.go
new file mode 100644
index 0000000..d8d2c1e
--- /dev/null
+++ b/internal/storage/postgres/care_instructions.go
@@ -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
+}
diff --git a/internal/storage/postgres/doc.go b/internal/storage/postgres/doc.go
new file mode 100644
index 0000000..a979e8b
--- /dev/null
+++ b/internal/storage/postgres/doc.go
@@ -0,0 +1,2 @@
+// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
+package postgres
diff --git a/internal/storage/postgres/garden_invites.go b/internal/storage/postgres/garden_invites.go
new file mode 100644
index 0000000..b6160c7
--- /dev/null
+++ b/internal/storage/postgres/garden_invites.go
@@ -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
+}
diff --git a/internal/storage/postgres/garden_members.go b/internal/storage/postgres/garden_members.go
new file mode 100644
index 0000000..9e88c61
--- /dev/null
+++ b/internal/storage/postgres/garden_members.go
@@ -0,0 +1,222 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// GardenMemberModel stores garden memberships in PostgreSQL.
+// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
+type GardenMemberModel struct{ DB *sql.DB }
+
+func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `
+ SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
+ UNION ALL
+ SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
+ ORDER BY granted DESC`, member.Role, member.GardenID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ base := []string{}
+ overrides := []storage.GardenRolePermissionOverride{}
+ for rows.Next() {
+ var permission storage.GardenPermission
+ var granted bool
+ if err := rows.Scan(&permission, &granted); err != nil {
+ return err
+ }
+ if granted {
+ base = append(base, string(permission))
+ } else {
+ overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ member.Permissions = storage.ResolveGardenPermissions(base, overrides)
+ return nil
+}
+
+func scanGardenMember(s scanner) (storage.GardenMember, error) {
+ var member storage.GardenMember
+ err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
+ return member, err
+}
+
+// Insert adds a user to a garden.
+func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO garden_members (garden_id, user_id, role)
+ SELECT $1, $2, $3
+ WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
+ RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
+ ).Scan(&member.JoinedAt)
+ if err != nil {
+ return storage.GardenMember{}, recordError(err)
+ }
+ if err = m.loadPermissions(&member); err != nil {
+ return storage.GardenMember{}, err
+ }
+ return member, nil
+}
+
+// Get returns a user's membership and effective permissions in a garden.
+func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
+ SELECT garden_id, user_id, role, joined_at
+ FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
+ if err != nil {
+ return storage.GardenMember{}, recordError(err)
+ }
+ if err = m.loadPermissions(&member); err != nil {
+ return storage.GardenMember{}, err
+ }
+ return member, nil
+}
+
+// GetAllForGarden lists garden members with effective permissions.
+func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `
+ SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
+ FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
+ ORDER BY joined_at, user_id`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ members := []storage.GardenMember{}
+ for rows.Next() {
+ var member storage.GardenMember
+ err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
+ if err != nil {
+ return nil, err
+ }
+ members = append(members, member)
+ if err = m.loadPermissions(&members[len(members)-1]); err != nil {
+ return nil, err
+ }
+ }
+ return members, rows.Err()
+}
+
+// Update changes a member's garden role.
+func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return storage.GardenMember{}, err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
+ return storage.GardenMember{}, err
+ }
+ var current storage.GardenRole
+ if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil {
+ return storage.GardenMember{}, recordError(err)
+ }
+ if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
+ var owners int
+ if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
+ return storage.GardenMember{}, err
+ }
+ if owners < 2 {
+ return storage.GardenMember{}, storage.ErrConflict
+ }
+ }
+ result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
+ if err != nil {
+ return storage.GardenMember{}, err
+ }
+ if count, countErr := result.RowsAffected(); countErr != nil {
+ return storage.GardenMember{}, countErr
+ } else if count == 0 {
+ return storage.GardenMember{}, storage.ErrRecordNotFound
+ }
+ if err = tx.Commit(); err != nil {
+ return storage.GardenMember{}, err
+ }
+ if err = m.loadPermissions(&member); err != nil {
+ return storage.GardenMember{}, err
+ }
+ return member, nil
+}
+
+// Delete removes a non-owner membership from a garden.
+func (m GardenMemberModel) Delete(gardenID, userID int) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
+ return err
+ }
+ var role storage.GardenRole
+ if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
+ return recordError(err)
+ }
+ if role == storage.GardenRoleOwner {
+ var owners int
+ if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
+ return err
+ }
+ if owners < 2 {
+ return storage.ErrConflict
+ }
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// TransferOwnership swaps owner and member roles atomically while preserving
+// the invariant that a garden always has one owner.
+func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
+ if fromUserID == toUserID {
+ return storage.ErrConflict
+ }
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
+ return err
+ }
+ var fromRole, toRole storage.GardenRole
+ if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
+ return recordError(err)
+ }
+ if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
+ return recordError(err)
+ }
+ if fromRole != storage.GardenRoleOwner {
+ return storage.ErrConflict
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/internal/storage/postgres/gardens.go b/internal/storage/postgres/gardens.go
new file mode 100644
index 0000000..5f82445
--- /dev/null
+++ b/internal/storage/postgres/gardens.go
@@ -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)
+}
diff --git a/internal/storage/postgres/helpers.go b/internal/storage/postgres/helpers.go
new file mode 100644
index 0000000..95bd1d2
--- /dev/null
+++ b/internal/storage/postgres/helpers.go
@@ -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
+}
diff --git a/internal/storage/postgres/images.go b/internal/storage/postgres/images.go
new file mode 100644
index 0000000..88f5648
--- /dev/null
+++ b/internal/storage/postgres/images.go
@@ -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
+}
diff --git a/internal/storage/postgres/journal.go b/internal/storage/postgres/journal.go
new file mode 100644
index 0000000..29915b3
--- /dev/null
+++ b/internal/storage/postgres/journal.go
@@ -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()
+}
diff --git a/internal/storage/postgres/journal_integration_test.go b/internal/storage/postgres/journal_integration_test.go
new file mode 100644
index 0000000..407036d
--- /dev/null
+++ b/internal/storage/postgres/journal_integration_test.go
@@ -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)
+ }
+}
diff --git a/internal/storage/postgres/locations.go b/internal/storage/postgres/locations.go
new file mode 100644
index 0000000..96d157a
--- /dev/null
+++ b/internal/storage/postgres/locations.go
@@ -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
+}
diff --git a/internal/storage/postgres/locations_integration_test.go b/internal/storage/postgres/locations_integration_test.go
new file mode 100644
index 0000000..1464911
--- /dev/null
+++ b/internal/storage/postgres/locations_integration_test.go
@@ -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)
+ }
+}
diff --git a/internal/storage/postgres/migrations/000001_initial_schema.down.sql b/internal/storage/postgres/migrations/000001_initial_schema.down.sql
new file mode 100644
index 0000000..74eba5e
--- /dev/null
+++ b/internal/storage/postgres/migrations/000001_initial_schema.down.sql
@@ -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;
diff --git a/internal/storage/postgres/migrations/000001_initial_schema.up.sql b/internal/storage/postgres/migrations/000001_initial_schema.up.sql
new file mode 100644
index 0000000..a7e1ff2
--- /dev/null
+++ b/internal/storage/postgres/migrations/000001_initial_schema.up.sql
@@ -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);
diff --git a/internal/storage/postgres/models.go b/internal/storage/postgres/models.go
new file mode 100644
index 0000000..aa64d21
--- /dev/null
+++ b/internal/storage/postgres/models.go
@@ -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},
+ }
+}
diff --git a/internal/storage/postgres/plant_locations.go b/internal/storage/postgres/plant_locations.go
new file mode 100644
index 0000000..5c6ec05
--- /dev/null
+++ b/internal/storage/postgres/plant_locations.go
@@ -0,0 +1,139 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// PlantLocationModel stores plant-to-location assignments in PostgreSQL.
+// PlantLocationModel implements storage.PlantLocationModelInterface for
+// PostgreSQL and verifies both plant and location membership in the garden.
+type PlantLocationModel struct{ DB *sql.DB }
+
+const plantLocationQualifiedColumns = `pl.id, pl.plant_id, pl.location_id, pl.quantity, pl.planted_at,
+ pl.removed_at, pl.notes, pl.created_at, pl.version`
+
+func scanPlantLocation(s scanner) (storage.PlantLocation, error) {
+ var plantLocation storage.PlantLocation
+ err := s.Scan(
+ &plantLocation.ID, &plantLocation.PlantID, &plantLocation.LocationID,
+ &plantLocation.Quantity, &plantLocation.PlantedAt, &plantLocation.RemovedAt,
+ &plantLocation.Notes, &plantLocation.CreatedAt, &plantLocation.Version,
+ )
+ return plantLocation, err
+}
+
+// Insert assigns a plant to a location in the same garden.
+func (m PlantLocationModel) Insert(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO plant_locations
+ (plant_id, location_id, quantity, planted_at, removed_at, notes)
+ SELECT $2, $3, $4, $5, $6, $7
+ FROM plants p, locations l
+ WHERE p.id = $2 AND p.garden_id = $1 AND l.id = $3 AND l.garden_id = $1
+ RETURNING id, created_at, version`,
+ gardenID, plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
+ plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
+ ).Scan(&plantLocation.ID, &plantLocation.CreatedAt, &plantLocation.Version)
+ if err != nil {
+ return storage.PlantLocation{}, recordError(err)
+ }
+ return plantLocation, nil
+}
+
+// Get returns a plant assignment within its garden.
+func (m PlantLocationModel) Get(gardenID, id int) (storage.PlantLocation, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ plantLocation, err := scanPlantLocation(m.DB.QueryRowContext(ctx,
+ `SELECT `+plantLocationQualifiedColumns+` FROM plant_locations pl
+ JOIN plants p ON p.id = pl.plant_id WHERE p.garden_id = $1 AND pl.id = $2`, gardenID, id))
+ if err != nil {
+ return storage.PlantLocation{}, recordError(err)
+ }
+ return plantLocation, nil
+}
+
+func (m PlantLocationModel) getAll(query string, gardenID, id int) ([]storage.PlantLocation, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, query, gardenID, id)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ plantLocations := []storage.PlantLocation{}
+ for rows.Next() {
+ plantLocation, err := scanPlantLocation(rows)
+ if err != nil {
+ return nil, err
+ }
+ plantLocations = append(plantLocations, plantLocation)
+ }
+ return plantLocations, rows.Err()
+}
+
+// GetAllForPlant lists a plant's current and historical placements.
+func (m PlantLocationModel) GetAllForPlant(gardenID, plantID int) ([]storage.PlantLocation, error) {
+ return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
+ FROM plant_locations pl JOIN plants p ON p.id = pl.plant_id
+ WHERE p.garden_id = $1 AND pl.plant_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, plantID)
+}
+
+// GetAllForLocation lists current and historical plant placements at a location.
+func (m PlantLocationModel) GetAllForLocation(gardenID, locationID int) ([]storage.PlantLocation, error) {
+ return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
+ FROM plant_locations pl JOIN locations l ON l.id = pl.location_id
+ WHERE l.garden_id = $1 AND pl.location_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, locationID)
+}
+
+// Update changes a plant assignment using optimistic locking.
+func (m PlantLocationModel) Update(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ result, err := m.DB.ExecContext(ctx, `
+ UPDATE plant_locations SET plant_id = $1, location_id = $2, quantity = $3,
+ planted_at = $4, removed_at = $5, notes = $6, version = version + 1
+ WHERE id = $7 AND version = $8
+ AND EXISTS (SELECT 1 FROM plants p WHERE p.id = plant_locations.plant_id AND p.garden_id = $9)
+ AND EXISTS (SELECT 1 FROM plants p WHERE p.id = $1 AND p.garden_id = $9)
+ AND EXISTS (SELECT 1 FROM locations l WHERE l.id = $2 AND l.garden_id = $9)`,
+ plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
+ plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
+ plantLocation.ID, plantLocation.Version, gardenID)
+ if err != nil {
+ return storage.PlantLocation{}, err
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return storage.PlantLocation{}, err
+ }
+ if rowsAffected == 0 {
+ return storage.PlantLocation{}, storage.ErrEditConflict
+ }
+ plantLocation.Version++
+ return plantLocation, nil
+}
+
+// Delete removes a plant assignment within its garden.
+func (m PlantLocationModel) Delete(gardenID, id int) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ result, err := m.DB.ExecContext(ctx, `DELETE FROM plant_locations pl USING plants p
+ WHERE pl.id = $2 AND p.id = pl.plant_id AND p.garden_id = $1`, gardenID, id)
+ if err != nil {
+ return err
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows == 0 {
+ return storage.ErrRecordNotFound
+ }
+ return nil
+}
diff --git a/internal/storage/postgres/plants.go b/internal/storage/postgres/plants.go
new file mode 100644
index 0000000..b703df7
--- /dev/null
+++ b/internal/storage/postgres/plants.go
@@ -0,0 +1,104 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// PlantModel stores garden-scoped plant instances in PostgreSQL.
+// PlantModel implements storage.PlantModelInterface for PostgreSQL and scopes
+// every concrete plant operation to its garden.
+type PlantModel struct{ DB *sql.DB }
+
+const plantColumns = `p.id, p.garden_id, p.species_id, p.name, p.notes, p.acquired_at, p.status,
+ p.removed_at, p.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), p.image_id, p.created_at, p.updated_at, p.version,
+ COALESCE(p.created_by, 0), COALESCE(p.updated_by, 0), COALESCE(p.planted_by, 0), COALESCE(u.name, '')`
+
+func scanPlant(s scanner) (storage.Plant, error) {
+ var plant storage.Plant
+ err := s.Scan(
+ &plant.ID, &plant.GardenID, &plant.SpeciesID, &plant.Name, &plant.Notes,
+ &plant.AcquiredAt, &plant.Status, &plant.RemovedAt, &plant.Attributes, &plant.ImageData, &plant.ImageID,
+ &plant.CreatedAt, &plant.UpdatedAt, &plant.Version, &plant.CreatedBy, &plant.UpdatedBy, &plant.PlantedBy, &plant.PlantedByName,
+ )
+ return plant, err
+}
+
+// Insert creates a plant in its garden.
+func (m PlantModel) Insert(plant storage.Plant) (storage.Plant, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO plants
+ (garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_data, image_id, created_by, updated_by, planted_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10, $11, $12)
+ RETURNING id, created_at, updated_at, version`,
+ plant.GardenID, plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt,
+ plant.Status, plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.CreatedBy), nullableUserID(plant.UpdatedBy), nullableUserID(plant.PlantedBy),
+ ).Scan(&plant.ID, &plant.CreatedAt, &plant.UpdatedAt, &plant.Version)
+ if err != nil {
+ return storage.Plant{}, recordError(err)
+ }
+ return plant, nil
+}
+
+// Get returns a plant within its garden.
+func (m PlantModel) Get(gardenID, id int) (storage.Plant, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ plant, err := scanPlant(m.DB.QueryRowContext(ctx, `SELECT `+plantColumns+` FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 AND p.id = $2`, gardenID, id))
+ if err != nil {
+ return storage.Plant{}, recordError(err)
+ }
+ return plant, nil
+}
+
+// GetAllForGarden lists plants in a garden.
+func (m PlantModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT `+plantColumns+`
+ FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 ORDER BY p.name, p.id`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ plants := []storage.Plant{}
+ for rows.Next() {
+ plant, err := scanPlant(rows)
+ if err != nil {
+ return nil, err
+ }
+ plants = append(plants, plant)
+ }
+ return plants, rows.Err()
+}
+
+// Update changes a plant using optimistic locking.
+func (m PlantModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ UPDATE plants SET species_id = $1, name = $2, notes = $3, acquired_at = $4,
+ status = $5, removed_at = $6, attributes = $7, image_data = '', image_id = $8, updated_by = $9,
+ updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE garden_id = $10 AND id = $11 AND version = $12
+ RETURNING updated_at, version`,
+ plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, plant.Status,
+ plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.UpdatedBy), gardenID, plant.ID, plant.Version,
+ ).Scan(&plant.UpdatedAt, &plant.Version)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return storage.Plant{}, storage.ErrEditConflict
+ }
+ return storage.Plant{}, recordError(err)
+ }
+ return plant, nil
+}
+
+// Delete removes a plant within its garden.
+func (m PlantModel) Delete(gardenID, id int) error {
+ return deleteByGardenID(m.DB, `DELETE FROM plants WHERE garden_id = $1 AND id = $2`, gardenID, id)
+}
diff --git a/internal/storage/postgres/roles.go b/internal/storage/postgres/roles.go
new file mode 100644
index 0000000..4165ead
--- /dev/null
+++ b/internal/storage/postgres/roles.go
@@ -0,0 +1,201 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+ "github.com/lib/pq"
+)
+
+// RoleModel implements storage.RoleModelInterface for shared and garden-owned
+// PostgreSQL roles.
+type RoleModel struct{ DB *sql.DB }
+
+// List returns shared roles in a scope.
+func (m RoleModel) List(scope storage.RoleScope) ([]storage.Role, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope=$1 AND r.garden_id IS NULL GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, scope)
+ return scanRoles(rows, err)
+}
+
+// ListForGarden returns shared and custom roles available in a garden.
+func (m RoleModel) ListForGarden(gardenID int) ([]storage.Role, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope='garden' AND (r.garden_id IS NULL OR r.garden_id=$1) GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, gardenID)
+ return scanRoles(rows, err)
+}
+
+func scanRoles(rows *sql.Rows, err error) ([]storage.Role, error) {
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ roles := []storage.Role{}
+ for rows.Next() {
+ var role storage.Role
+ var permissions pq.StringArray
+ if err := rows.Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions); err != nil {
+ return nil, err
+ }
+ role.Permissions = []string(permissions)
+ roles = append(roles, role)
+ }
+ return roles, rows.Err()
+}
+
+// Get returns a shared role by name.
+func (m RoleModel) Get(name string) (storage.Role, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ var role storage.Role
+ var permissions pq.StringArray
+ err := m.DB.QueryRowContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.name=$1 GROUP BY r.name`, name).Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions)
+ if err != nil {
+ return storage.Role{}, recordError(err)
+ }
+ role.Permissions = []string(permissions)
+ return role, nil
+}
+
+// GetForGarden returns a shared or custom role available in a garden.
+func (m RoleModel) GetForGarden(gardenID int, name string) (storage.Role, error) {
+ role, err := m.Get(name)
+ if err != nil {
+ return storage.Role{}, err
+ }
+ if role.Scope != storage.RoleScopeGarden || role.GardenID != nil && *role.GardenID != gardenID {
+ return storage.Role{}, storage.ErrRecordNotFound
+ }
+ return role, nil
+}
+
+// Create adds a role and its base permissions atomically.
+func (m RoleModel) Create(role storage.Role) (storage.Role, error) {
+ if role.Scope != storage.RoleScopeApplication && role.Scope != storage.RoleScopeGarden {
+ return storage.Role{}, storage.ErrConflict
+ }
+ if role.Scope == storage.RoleScopeApplication && role.GardenID != nil {
+ return storage.Role{}, storage.ErrConflict
+ }
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return role, err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `INSERT INTO roles(name,scope,garden_id,label) VALUES($1,$2,$3,$4)`, role.Name, role.Scope, role.GardenID, role.Label); err != nil {
+ return role, recordError(err)
+ }
+ for _, p := range role.Permissions {
+ if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil {
+ return role, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return role, err
+ }
+ return m.Get(role.Name)
+}
+
+// Update replaces a role's label and base permissions atomically.
+func (m RoleModel) Update(role storage.Role) (storage.Role, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return role, err
+ }
+ defer tx.Rollback()
+ result, err := tx.ExecContext(ctx, `UPDATE roles SET label=$1,updated_at=CURRENT_TIMESTAMP WHERE name=$2`, role.Label, role.Name)
+ if err != nil {
+ return role, err
+ }
+ if count, countErr := result.RowsAffected(); countErr != nil {
+ return role, countErr
+ } else if count == 0 {
+ return storage.Role{}, storage.ErrRecordNotFound
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_name=$1`, role.Name); err != nil {
+ return role, err
+ }
+ for _, p := range role.Permissions {
+ if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil {
+ return role, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return role, err
+ }
+ return m.Get(role.Name)
+}
+
+// Delete removes a non-system shared role that is not assigned.
+func (m RoleModel) Delete(name string) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ result, err := m.DB.ExecContext(ctx, `DELETE FROM roles WHERE name=$1 AND system=false`, name)
+ if err != nil {
+ return recordError(err)
+ }
+ count, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ return storage.ErrConflict
+ }
+ return nil
+}
+
+// ListGardenOverrides lists explicit permission grants and revocations in a garden.
+func (m RoleModel) ListGardenOverrides(gardenID int) ([]storage.GardenRolePermissionOverride, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT garden_id,role_name,permission,granted FROM garden_role_permission_overrides WHERE garden_id=$1 ORDER BY role_name,permission`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []storage.GardenRolePermissionOverride{}
+ for rows.Next() {
+ var o storage.GardenRolePermissionOverride
+ if err := rows.Scan(&o.GardenID, &o.RoleName, &o.Permission, &o.Granted); err != nil {
+ return nil, err
+ }
+ result = append(result, o)
+ }
+ return result, rows.Err()
+}
+
+// ReplaceGardenOverrides atomically replaces all permission overrides for a
+// role in one garden.
+func (m RoleModel) ReplaceGardenOverrides(gardenID int, roleName string, overrides []storage.GardenRolePermissionOverride) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ var exists bool
+ if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, roleName, gardenID).Scan(&exists); err != nil {
+ return err
+ }
+ if !exists {
+ return storage.ErrRecordNotFound
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM garden_role_permission_overrides WHERE garden_id=$1 AND role_name=$2`, gardenID, roleName); err != nil {
+ return err
+ }
+ for _, o := range overrides {
+ if _, err = tx.ExecContext(ctx, `INSERT INTO garden_role_permission_overrides(garden_id,role_name,permission,granted) VALUES($1,$2,$3,$4)`, gardenID, roleName, o.Permission, o.Granted); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+var _ = sql.ErrNoRows
diff --git a/internal/storage/postgres/roles_integration_test.go b/internal/storage/postgres/roles_integration_test.go
new file mode 100644
index 0000000..a436f2e
--- /dev/null
+++ b/internal/storage/postgres/roles_integration_test.go
@@ -0,0 +1,103 @@
+package postgres
+
+import (
+ "database/sql"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "gardomatic.kleiax.de/internal/storage"
+ _ "github.com/lib/pq"
+)
+
+func TestScopedRolesAndPersistedPermissions(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")
+ password := auth.NewPassword([]byte("integration-test-hash"))
+ users := UserModel{DB: db}
+ user, err := users.Insert(storage.User{Name: "Role integration", Email: "roles-" + stamp + "@example.com", Password: *password, Activated: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, user.ID) })
+ if user.Role != storage.ApplicationRoleUser || !user.Can(storage.ApplicationPermissionGardensCreate) {
+ t.Fatalf("new user role=%q permissions=%v", user.Role, user.Permissions)
+ }
+
+ user, err = users.UpdateRole(user.ID, storage.ApplicationRoleAdmin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !user.Can(storage.ApplicationPermissionUsersManage) || !user.Can(storage.ApplicationPermissionRolesManage) {
+ t.Fatalf("admin permissions were not loaded: %v", user.Permissions)
+ }
+
+ var gardenID int
+ if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Role integration "+stamp).Scan(&gardenID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) })
+ members := GardenMemberModel{DB: db}
+ member, err := members.Insert(storage.GardenMember{GardenID: gardenID, UserID: user.ID, Role: storage.GardenRoleOwner})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !member.Can(storage.GardenPermissionGardenDelete) {
+ t.Fatal("owner wildcard permission was not expanded")
+ }
+ if err = (RoleModel{DB: db}).ReplaceGardenOverrides(gardenID, string(storage.GardenRoleOwner), []storage.GardenRolePermissionOverride{{GardenID: gardenID, RoleName: string(storage.GardenRoleOwner), Permission: string(storage.GardenPermissionGardenDelete), Granted: false}}); err != nil {
+ t.Fatal(err)
+ }
+ member, err = members.Get(gardenID, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if member.Can(storage.GardenPermissionGardenDelete) || !member.Can(storage.GardenPermissionGardenUpdate) {
+ t.Fatalf("garden override was not applied: %v", member.Permissions)
+ }
+
+ var otherGardenID int
+ if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Other role integration "+stamp).Scan(&otherGardenID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, otherGardenID) })
+ roleName := "garden:" + strconv.Itoa(gardenID) + ":integration"
+ role, err := (RoleModel{DB: db}).Create(storage.Role{
+ Name: roleName, Scope: storage.RoleScopeGarden, GardenID: &gardenID,
+ Label: "Integration", Permissions: []string{string(storage.GardenPermissionGardenRead)},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if role.GardenID == nil || *role.GardenID != gardenID {
+ t.Fatalf("garden-specific role has garden_id=%v", role.GardenID)
+ }
+ roles, err := (RoleModel{DB: db}).ListForGarden(gardenID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, candidate := range roles {
+ found = found || candidate.Name == roleName
+ }
+ if !found {
+ t.Fatalf("garden-specific role %q is missing", roleName)
+ }
+ if _, err = (RoleModel{DB: db}).GetForGarden(otherGardenID, roleName); err != storage.ErrRecordNotFound {
+ t.Fatalf("role leaked into another garden: %v", err)
+ }
+}
diff --git a/internal/storage/postgres/species.go b/internal/storage/postgres/species.go
new file mode 100644
index 0000000..cd8b4e1
--- /dev/null
+++ b/internal/storage/postgres/species.go
@@ -0,0 +1,142 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// SpeciesModel stores global and garden-specific species data in PostgreSQL.
+// SpeciesModel implements storage.SpeciesModelInterface for global and
+// garden-owned species records.
+type SpeciesModel struct{ DB *sql.DB }
+
+const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name,
+ s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction,
+ s.winter_protection, s.spacing_cm, s.height_cm,
+ s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to,
+ s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
+ s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
+ s.notes, s.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), s.image_id, s.created_at, s.updated_at, s.version,
+ COALESCE(s.created_by, 0), COALESCE(s.updated_by, 0)`
+
+func scanSpecies(s scanner) (storage.Species, error) {
+ var species storage.Species
+ err := s.Scan(
+ &species.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
+ &species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
+ &species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
+ &species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
+ &species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom,
+ &species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
+ &species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
+ )
+ return species, err
+}
+
+func speciesArgs(species storage.Species) []any {
+ return []any{
+ species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
+ species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
+ species.WinterProtection, species.SpacingCM, species.HeightCM,
+ species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
+ species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
+ species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
+ species.HarvestDayTo, species.Notes, jsonValue(species.Attributes), species.ImageID, nullableUserID(species.CreatedBy), nullableUserID(species.UpdatedBy),
+ }
+}
+
+// Insert creates a global or garden-owned species record.
+func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ query := `
+ INSERT INTO species (
+ garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
+ soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm,
+ sow_month_from, sow_day_from, sow_month_to, sow_day_to,
+ planting_month_from, planting_day_from, planting_month_to, planting_day_to,
+ harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
+ notes, attributes, image_data, image_id, created_by, updated_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
+ $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28)
+ RETURNING id, created_at, updated_at, version`
+ err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
+ &species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
+ )
+ if err != nil {
+ return storage.Species{}, recordError(err)
+ }
+ return species, nil
+}
+
+// Get returns a global or garden-owned species visible in gardenID.
+func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ species, err := scanSpecies(m.DB.QueryRowContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.id = $1 AND (s.garden_id IS NULL OR s.garden_id = $2)`, id, gardenID))
+ if err != nil {
+ return storage.Species{}, recordError(err)
+ }
+ return species, nil
+}
+
+// GetAllForGarden lists global species and species owned by a garden.
+func (m SpeciesModel) GetAllForGarden(gardenID int) ([]storage.Species, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesColumns+`
+ FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id
+ WHERE s.garden_id IS NULL OR s.garden_id = $1
+ ORDER BY s.common_name, s.cultivar, s.id`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ allSpecies := []storage.Species{}
+ for rows.Next() {
+ species, err := scanSpecies(rows)
+ if err != nil {
+ return nil, err
+ }
+ allSpecies = append(allSpecies, species)
+ }
+ return allSpecies, rows.Err()
+}
+
+// Update changes a visible species using optimistic locking.
+func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Species, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ args := speciesArgs(species)
+ // created_by is immutable and is intentionally not part of the UPDATE.
+ // Remove it from the shared insert argument list so PostgreSQL does not
+ // receive an unused, untyped parameter between image_id and updated_by.
+ args = append(args[:26], args[27])
+ args = append(args, gardenID, species.ID, species.Version)
+ err := m.DB.QueryRowContext(ctx, `
+ UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
+ botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
+ soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
+ height_cm = $11, sow_month_from = $12, sow_day_from = $13,
+ sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
+ planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
+ harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
+ notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30
+ RETURNING updated_at, version`, args...,
+ ).Scan(&species.UpdatedAt, &species.Version)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return storage.Species{}, storage.ErrEditConflict
+ }
+ return storage.Species{}, recordError(err)
+ }
+ return species, nil
+}
+
+// Delete removes a species owned by the supplied garden.
+func (m SpeciesModel) Delete(gardenID, id int) error {
+ return deleteByGardenID(m.DB, `DELETE FROM species WHERE garden_id IS NOT DISTINCT FROM NULLIF($1, 0) AND id = $2`, gardenID, id)
+}
diff --git a/internal/storage/postgres/species_categories.go b/internal/storage/postgres/species_categories.go
new file mode 100644
index 0000000..39d0a86
--- /dev/null
+++ b/internal/storage/postgres/species_categories.go
@@ -0,0 +1,86 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// SpeciesCategoryModel stores globally configured species categories.
+// SpeciesCategoryModel implements storage.SpeciesCategoryModelInterface for
+// the application-wide category catalogue.
+type SpeciesCategoryModel struct{ DB *sql.DB }
+
+func scanSpeciesCategory(s scanner) (storage.SpeciesCategory, error) {
+ var category storage.SpeciesCategory
+ err := s.Scan(&category.ID, &category.Name, &category.SortOrder, &category.Active, &category.CreatedAt, &category.UpdatedAt, &category.Version, &category.Lifecycle)
+ return category, err
+}
+
+// Insert creates a species category.
+func (m SpeciesCategoryModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO species_categories (name, sort_order, active, lifecycle)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, created_at, updated_at, version`, category.Name, category.SortOrder, category.Active,
+ category.Lifecycle).Scan(&category.ID, &category.CreatedAt, &category.UpdatedAt, &category.Version)
+ if err != nil {
+ return storage.SpeciesCategory{}, recordError(err)
+ }
+ return category, nil
+}
+
+// Get returns a species category by ID.
+func (m SpeciesCategoryModel) Get(id int) (storage.SpeciesCategory, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ category, err := scanSpeciesCategory(m.DB.QueryRowContext(ctx, `
+ SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle
+ FROM species_categories WHERE id = $1`, id))
+ if err != nil {
+ return storage.SpeciesCategory{}, recordError(err)
+ }
+ return category, nil
+}
+
+// GetAll lists all species categories in display order.
+func (m SpeciesCategoryModel) GetAll() ([]storage.SpeciesCategory, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `
+ SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle
+ FROM species_categories ORDER BY sort_order, lower(name), id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ categories := []storage.SpeciesCategory{}
+ for rows.Next() {
+ category, scanErr := scanSpeciesCategory(rows)
+ if scanErr != nil {
+ return nil, scanErr
+ }
+ categories = append(categories, category)
+ }
+ return categories, rows.Err()
+}
+
+// Update changes a species category using optimistic locking.
+func (m SpeciesCategoryModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ UPDATE species_categories
+ SET name = $1, sort_order = $2, active = $3, lifecycle = $4, updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE id = $5 AND version = $6
+ RETURNING updated_at, version`, category.Name, category.SortOrder, category.Active, category.Lifecycle, category.ID, category.Version).Scan(&category.UpdatedAt, &category.Version)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return storage.SpeciesCategory{}, storage.ErrEditConflict
+ }
+ return storage.SpeciesCategory{}, recordError(err)
+ }
+ return category, nil
+}
diff --git a/internal/storage/postgres/species_integration_test.go b/internal/storage/postgres/species_integration_test.go
new file mode 100644
index 0000000..560764f
--- /dev/null
+++ b/internal/storage/postgres/species_integration_test.go
@@ -0,0 +1,35 @@
+package postgres
+
+import (
+ "database/sql"
+ "os"
+ "testing"
+)
+
+func TestSpeciesModelListsSeededGlobalSpecies(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)
+ }
+
+ species, err := (SpeciesModel{DB: db}).GetAllForGarden(2_147_483_647)
+ if err != nil {
+ t.Fatalf("list global species: %v", err)
+ }
+
+ for _, item := range species {
+ if item.GardenID == nil && item.CommonName == "Tomate" {
+ return
+ }
+ }
+ t.Fatal("seeded global species Tomate was not returned")
+}
diff --git a/internal/storage/postgres/species_task_templates.go b/internal/storage/postgres/species_task_templates.go
new file mode 100644
index 0000000..31b4a58
--- /dev/null
+++ b/internal/storage/postgres/species_task_templates.go
@@ -0,0 +1,124 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// SpeciesTaskTemplateModel stores recurring species task rules in PostgreSQL.
+// SpeciesTaskTemplateModel implements storage.SpeciesTaskTemplateModelInterface
+// and resolves global species through the requesting garden boundary.
+type SpeciesTaskTemplateModel struct{ DB *sql.DB }
+
+const speciesTaskTemplateColumns = `t.id, t.species_id, t.origin, t.title, t.description, t.trigger_type,
+ t.month_from, t.day_from, t.month_to, t.day_to, t.offset_days_from, t.offset_days_to,
+ t.interval_days, t.trigger_offset, t.trigger_offset_unit, t.duration, t.duration_unit,
+ t.recurrence, t.recurrence_interval, t.priority, t.active, t.created_at, t.updated_at, t.version`
+
+func scanSpeciesTaskTemplate(s scanner) (storage.SpeciesTaskTemplate, error) {
+ var template storage.SpeciesTaskTemplate
+ err := s.Scan(
+ &template.ID, &template.SpeciesID, &template.Origin, &template.Title, &template.Description,
+ &template.TriggerType, &template.MonthFrom, &template.DayFrom, &template.MonthTo,
+ &template.DayTo, &template.OffsetDaysFrom, &template.OffsetDaysTo,
+ &template.IntervalDays, &template.TriggerOffset, &template.TriggerOffsetUnit, &template.Duration, &template.DurationUnit,
+ &template.Recurrence, &template.RecurrenceInterval, &template.Priority, &template.Active, &template.CreatedAt,
+ &template.UpdatedAt, &template.Version,
+ )
+ return template, err
+}
+
+// Insert creates a task template for a species.
+func (m SpeciesTaskTemplateModel) Insert(template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO species_task_templates (
+ species_id, origin, title, description, trigger_type, month_from, day_from,
+ month_to, day_to, offset_days_from, offset_days_to, interval_days,
+ trigger_offset, trigger_offset_unit, duration, duration_unit, recurrence, recurrence_interval, priority, active)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
+ RETURNING id, created_at, updated_at, version`,
+ template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType,
+ template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo,
+ template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays,
+ template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit,
+ template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active,
+ ).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt, &template.Version)
+ if err != nil {
+ return storage.SpeciesTaskTemplate{}, err
+ }
+ return template, nil
+}
+
+// Get returns a task template visible in a garden.
+func (m SpeciesTaskTemplateModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ template, err := scanSpeciesTaskTemplate(m.DB.QueryRowContext(ctx,
+ `SELECT `+speciesTaskTemplateColumns+` FROM species_task_templates t JOIN species s ON s.id = t.species_id WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.id = $2`, gardenID, id))
+ if err != nil {
+ return storage.SpeciesTaskTemplate{}, recordError(err)
+ }
+ return template, nil
+}
+
+// GetAllForSpecies lists task templates for a species visible in a garden.
+func (m SpeciesTaskTemplateModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesTaskTemplateColumns+`
+ FROM species_task_templates t JOIN species s ON s.id = t.species_id
+ WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.species_id = $2
+ ORDER BY t.active DESC, t.priority DESC, t.title, t.id`, gardenID, speciesID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ templates := []storage.SpeciesTaskTemplate{}
+ for rows.Next() {
+ template, err := scanSpeciesTaskTemplate(rows)
+ if err != nil {
+ return nil, err
+ }
+ templates = append(templates, template)
+ }
+ return templates, rows.Err()
+}
+
+// Update changes a task template using optimistic locking.
+func (m SpeciesTaskTemplateModel) Update(gardenID int, template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ UPDATE species_task_templates SET species_id = $1, origin = $2, title = $3, description = $4,
+ trigger_type = $5, month_from = $6, day_from = $7, month_to = $8,
+ day_to = $9, offset_days_from = $10, offset_days_to = $11,
+ interval_days = $12, trigger_offset = $13, trigger_offset_unit = $14,
+ duration = $15, duration_unit = $16, recurrence = $17, recurrence_interval = $18,
+ priority = $19, active = $20,
+ updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE id = $21 AND version = $22
+ AND EXISTS (SELECT 1 FROM species s WHERE s.id = species_task_templates.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($23, 0))
+ RETURNING updated_at, version`,
+ template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType,
+ template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo,
+ template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays,
+ template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit,
+ template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, template.ID, template.Version, gardenID,
+ ).Scan(&template.UpdatedAt, &template.Version)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return storage.SpeciesTaskTemplate{}, storage.ErrEditConflict
+ }
+ return storage.SpeciesTaskTemplate{}, err
+ }
+ return template, nil
+}
+
+// Delete removes a task template visible in a garden.
+func (m SpeciesTaskTemplateModel) Delete(gardenID, id int) error {
+ return deleteByID(m.DB, `DELETE FROM species_task_templates t USING species s WHERE t.id = $1 AND s.id = t.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($2, 0)`, id, gardenID)
+}
diff --git a/internal/storage/postgres/tags.go b/internal/storage/postgres/tags.go
new file mode 100644
index 0000000..b17cff6
--- /dev/null
+++ b/internal/storage/postgres/tags.go
@@ -0,0 +1,108 @@
+package postgres
+
+import (
+ "database/sql"
+ "fmt"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// TagModel manages global and garden-local tags for supported entity types.
+type TagModel struct{ DB *sql.DB }
+
+// GetAllForGarden lists global and garden-local tag names.
+func (m TagModel) GetAllForGarden(gardenID int) ([]string, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT DISTINCT name FROM tags WHERE garden_id=$1 ORDER BY name`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []string{}
+ for rows.Next() {
+ var value string
+ if err = rows.Scan(&value); err != nil {
+ return nil, err
+ }
+ result = append(result, value)
+ }
+ return result, rows.Err()
+}
+
+func tagRelation(entity storage.TagEntity) (string, string, error) {
+ switch entity {
+ case storage.TagEntityTask:
+ return "task_tags", "task_id", nil
+ case storage.TagEntityPlant:
+ return "plant_tags", "plant_id", nil
+ case storage.TagEntitySpecies:
+ return "species_tags", "species_id", nil
+ case storage.TagEntityJournal:
+ return "journal_entry_tags", "journal_entry_id", nil
+ default:
+ return "", "", fmt.Errorf("unknown tag entity %q", entity)
+ }
+}
+
+// Get lists tags assigned to an entity within its garden.
+func (m TagModel) Get(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) {
+ table, column, err := tagRelation(entity)
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT t.name FROM tags t JOIN `+table+` et ON et.tag_id=t.id WHERE t.garden_id IS NOT DISTINCT FROM NULLIF($1,0) AND et.`+column+`=$2 ORDER BY t.name`, gardenID, entityID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []string{}
+ for rows.Next() {
+ var value string
+ if err = rows.Scan(&value); err != nil {
+ return nil, err
+ }
+ result = append(result, value)
+ }
+ return result, rows.Err()
+}
+
+// Set atomically replaces an entity's tags after verifying that the entity
+// belongs to the supplied garden.
+func (m TagModel) Set(gardenID int, entity storage.TagEntity, entityID int, tags []string) ([]string, error) {
+ table, column, err := tagRelation(entity)
+ if err != nil {
+ return nil, err
+ }
+ tags = storage.NormalizeTags(tags)
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE `+column+`=$1`, entityID); err != nil {
+ return nil, err
+ }
+ for _, tag := range tags {
+ var tagID int
+ if gardenID == 0 {
+ err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES(NULL,$1) ON CONFLICT(name) WHERE garden_id IS NULL DO UPDATE SET name=EXCLUDED.name RETURNING id`, tag).Scan(&tagID)
+ } else {
+ err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES($1,$2) ON CONFLICT(garden_id,name) DO UPDATE SET name=EXCLUDED.name RETURNING id`, gardenID, tag).Scan(&tagID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if _, err = tx.ExecContext(ctx, `INSERT INTO `+table+`(`+column+`,tag_id) VALUES($1,$2)`, entityID, tagID); err != nil {
+ return nil, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return nil, err
+ }
+ return tags, nil
+}
diff --git a/internal/storage/postgres/task_generation_integration_test.go b/internal/storage/postgres/task_generation_integration_test.go
new file mode 100644
index 0000000..8e670d8
--- /dev/null
+++ b/internal/storage/postgres/task_generation_integration_test.go
@@ -0,0 +1,81 @@
+package postgres
+
+import (
+ "database/sql"
+ "errors"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+func TestGeneratedTaskInsertIsIdempotentUnderConcurrency(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)
+ }
+ var userID, gardenID, speciesID, plantID, templateID int
+ if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Generator integration', $1, 'hash', true) RETURNING id`, "generator-"+time.Now().Format("150405.000000000")+"@example.com").Scan(&userID); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Generator integration') RETURNING id`).Scan(&gardenID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID)
+ _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
+ })
+ if err := db.QueryRow(`INSERT INTO species (garden_id, common_name) VALUES ($1, 'Parallelrose') RETURNING id`, gardenID).Scan(&speciesID); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(`INSERT INTO plants (garden_id, species_id, name) VALUES ($1, $2, 'Rose') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, month_to) VALUES ($1, 'Schneiden', 'month_of_year', 2, 3) RETURNING id`, speciesID).Scan(&templateID); err != nil {
+ t.Fatal(err)
+ }
+
+ generatedFor := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+ dueStart, dueEnd := generatedFor, time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC)
+ model := TaskModel{DB: db}
+ const workers = 12
+ results := make(chan error, workers)
+ var wg sync.WaitGroup
+ for range workers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := model.Insert(storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: "Schneiden", DueAtStart: &dueStart, DueAtEnd: &dueEnd, GeneratedFor: &generatedFor, CreatedBy: userID})
+ results <- err
+ }()
+ }
+ wg.Wait()
+ close(results)
+ successes, conflicts := 0, 0
+ for err := range results {
+ if err == nil {
+ successes++
+ } else if errors.Is(err, storage.ErrConflict) {
+ conflicts++
+ } else {
+ t.Fatalf("insert error: %v", err)
+ }
+ }
+ if successes != 1 || conflicts != workers-1 {
+ t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
+ }
+ var count int
+ if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE plant_id = $1 AND template_id = $2 AND generated_for = $3`, plantID, templateID, generatedFor).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("count=%d err=%v", count, err)
+ }
+}
diff --git a/internal/storage/postgres/task_priorities.go b/internal/storage/postgres/task_priorities.go
new file mode 100644
index 0000000..7dc8875
--- /dev/null
+++ b/internal/storage/postgres/task_priorities.go
@@ -0,0 +1,73 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// TaskPriorityModel implements storage.TaskPriorityModelInterface for the
+// application-wide priority catalogue.
+type TaskPriorityModel struct{ DB *sql.DB }
+
+func scanTaskPriority(s scanner) (storage.TaskPriority, error) {
+ var priority storage.TaskPriority
+ err := s.Scan(&priority.ID, &priority.Name, &priority.Value, &priority.SortOrder, &priority.Active, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version)
+ return priority, err
+}
+
+// Insert creates a priority catalogue entry.
+func (m TaskPriorityModel) Insert(priority storage.TaskPriority) (storage.TaskPriority, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `INSERT INTO task_priorities (name, value, sort_order, active) VALUES ($1,$2,$3,$4) RETURNING id, created_at, updated_at, version`, priority.Name, priority.Value, priority.SortOrder, priority.Active).Scan(&priority.ID, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version)
+ if err != nil {
+ return storage.TaskPriority{}, recordError(err)
+ }
+ return priority, nil
+}
+
+// Get returns a priority catalogue entry by ID.
+func (m TaskPriorityModel) Get(id int) (storage.TaskPriority, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ priority, err := scanTaskPriority(m.DB.QueryRowContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities WHERE id=$1`, id))
+ if err != nil {
+ return storage.TaskPriority{}, recordError(err)
+ }
+ return priority, nil
+}
+
+// GetAll lists all priority catalogue entries in display order.
+func (m TaskPriorityModel) GetAll() ([]storage.TaskPriority, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities ORDER BY sort_order, value, id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []storage.TaskPriority{}
+ for rows.Next() {
+ value, err := scanTaskPriority(rows)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, value)
+ }
+ return result, rows.Err()
+}
+
+// Update changes a priority catalogue entry using optimistic locking.
+func (m TaskPriorityModel) Update(priority storage.TaskPriority) (storage.TaskPriority, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `UPDATE task_priorities SET name=$1,value=$2,sort_order=$3,active=$4,updated_at=CURRENT_TIMESTAMP,version=version+1 WHERE id=$5 AND version=$6 RETURNING updated_at,version`, priority.Name, priority.Value, priority.SortOrder, priority.Active, priority.ID, priority.Version).Scan(&priority.UpdatedAt, &priority.Version)
+ if err == sql.ErrNoRows {
+ return storage.TaskPriority{}, storage.ErrEditConflict
+ }
+ if err != nil {
+ return storage.TaskPriority{}, recordError(err)
+ }
+ return priority, nil
+}
diff --git a/internal/storage/postgres/task_template_opt_outs.go b/internal/storage/postgres/task_template_opt_outs.go
new file mode 100644
index 0000000..2b9f756
--- /dev/null
+++ b/internal/storage/postgres/task_template_opt_outs.go
@@ -0,0 +1,60 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// TaskTemplateOptOutModel stores per-plant task-generation suppression while
+// enforcing the garden boundary.
+type TaskTemplateOptOutModel struct{ DB *sql.DB }
+
+// IsOptedOut reports whether task generation is suppressed for a plant-template pair.
+func (m TaskTemplateOptOutModel) IsOptedOut(gardenID, plantID, templateID int) (bool, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ var value bool
+ err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id JOIN species_task_templates st ON st.id=o.template_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3 AND st.species_id=p.species_id)`, gardenID, plantID, templateID).Scan(&value)
+ return value, err
+}
+
+// GetAllForPlant lists suppressed template IDs for a plant.
+func (m TaskTemplateOptOutModel) GetAllForPlant(gardenID, plantID int) ([]int, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT o.template_id FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id WHERE p.garden_id=$1 AND p.id=$2 ORDER BY o.template_id`, gardenID, plantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []int{}
+ for rows.Next() {
+ var id int
+ if err = rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ result = append(result, id)
+ }
+ return result, rows.Err()
+}
+
+// Set creates or removes suppression for a plant-template pair.
+func (m TaskTemplateOptOutModel) Set(gardenID, plantID, templateID int, optedOut bool) error {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ var valid bool
+ err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM plants p JOIN species_task_templates st ON st.species_id=p.species_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3)`, gardenID, plantID, templateID).Scan(&valid)
+ if err != nil {
+ return err
+ }
+ if !valid {
+ return storage.ErrRecordNotFound
+ }
+ if optedOut {
+ _, err = m.DB.ExecContext(ctx, `INSERT INTO task_template_opt_outs(plant_id,template_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, plantID, templateID)
+ } else {
+ _, err = m.DB.ExecContext(ctx, `DELETE FROM task_template_opt_outs WHERE plant_id=$1 AND template_id=$2`, plantID, templateID)
+ }
+ return err
+}
diff --git a/internal/storage/postgres/tasks.go b/internal/storage/postgres/tasks.go
new file mode 100644
index 0000000..6a5083c
--- /dev/null
+++ b/internal/storage/postgres/tasks.go
@@ -0,0 +1,116 @@
+package postgres
+
+import (
+ "database/sql"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+// TaskModel stores garden tasks in PostgreSQL.
+// TaskModel implements storage.TaskModelInterface for garden-scoped work items.
+type TaskModel struct{ DB *sql.DB }
+
+const taskColumns = `id, garden_id, plant_id, location_id, template_id, title,
+ description, due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by,
+ priority, active, created_by, created_at, updated_at, version, plant_status_on_completion`
+
+func scanTask(s scanner) (storage.Task, error) {
+ var task storage.Task
+ err := s.Scan(
+ &task.ID, &task.GardenID, &task.PlantID, &task.LocationID, &task.TemplateID,
+ &task.Title, &task.Description, &task.DueAtStart, &task.DueAtEnd,
+ &task.GeneratedFor, &task.Recurrence, &task.RecurrenceInterval, &task.RepeatFromID, &task.CompletedAt, &task.CompletedBy, &task.Priority, &task.Active,
+ &task.CreatedBy, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.PlantStatusOnCompletion,
+ )
+ return task, err
+}
+
+// Insert creates a task; generated task slots remain idempotent under concurrency.
+func (m TaskModel) Insert(task storage.Task) (storage.Task, error) {
+ if task.RecurrenceInterval < 1 {
+ task.RecurrenceInterval = 1
+ }
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ INSERT INTO tasks (
+ garden_id, plant_id, location_id, template_id, title, description,
+ due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by,
+ priority, active, created_by, plant_status_on_completion)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
+ RETURNING id, created_at, updated_at, version`,
+ task.GardenID, task.PlantID, task.LocationID, task.TemplateID, task.Title,
+ task.Description, task.DueAtStart, task.DueAtEnd, task.GeneratedFor,
+ task.Recurrence, task.RecurrenceInterval, task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.CreatedBy, task.PlantStatusOnCompletion,
+ ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt, &task.Version)
+ if err != nil {
+ return storage.Task{}, recordError(err)
+ }
+ return task, nil
+}
+
+// Get returns a task within its garden.
+func (m TaskModel) Get(gardenID, id int) (storage.Task, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ task, err := scanTask(m.DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id))
+ if err != nil {
+ return storage.Task{}, recordError(err)
+ }
+ return task, nil
+}
+
+// GetAllForGarden lists tasks in a garden.
+func (m TaskModel) GetAllForGarden(gardenID int) ([]storage.Task, error) {
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT `+taskColumns+`
+ FROM tasks WHERE garden_id = $1
+ ORDER BY completed_at NULLS FIRST, due_at_end NULLS LAST, priority DESC, id`, gardenID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ tasks := []storage.Task{}
+ for rows.Next() {
+ task, err := scanTask(rows)
+ if err != nil {
+ return nil, err
+ }
+ tasks = append(tasks, task)
+ }
+ return tasks, rows.Err()
+}
+
+// Update changes a task using optimistic locking.
+func (m TaskModel) Update(gardenID int, task storage.Task) (storage.Task, error) {
+ if task.RecurrenceInterval < 1 {
+ task.RecurrenceInterval = 1
+ }
+ ctx, cancel := contextWithTimeout()
+ defer cancel()
+ err := m.DB.QueryRowContext(ctx, `
+ UPDATE tasks SET plant_id = $1, location_id = $2, template_id = $3,
+ title = $4, description = $5, due_at_start = $6, due_at_end = $7,
+ generated_for = $8, recurrence = $9, recurrence_interval = $10, repeat_from_id = $11, completed_at = $12, completed_by = $13,
+ priority = $14, active = $15, plant_status_on_completion=$16, updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE garden_id = $17 AND id = $18 AND version = $19
+ RETURNING updated_at, version`,
+ task.PlantID, task.LocationID, task.TemplateID, task.Title, task.Description,
+ task.DueAtStart, task.DueAtEnd, task.GeneratedFor, task.Recurrence, task.RecurrenceInterval,
+ task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.PlantStatusOnCompletion, gardenID, task.ID, task.Version,
+ ).Scan(&task.UpdatedAt, &task.Version)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return storage.Task{}, storage.ErrEditConflict
+ }
+ return storage.Task{}, err
+ }
+ return task, nil
+}
+
+// Delete removes a task within its garden.
+func (m TaskModel) Delete(gardenID, id int) error {
+ return deleteByID(m.DB, `DELETE FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id)
+}
diff --git a/internal/storage/postgres/tasks_integration_test.go b/internal/storage/postgres/tasks_integration_test.go
new file mode 100644
index 0000000..bff037e
--- /dev/null
+++ b/internal/storage/postgres/tasks_integration_test.go
@@ -0,0 +1,64 @@
+package postgres
+
+import (
+ "database/sql"
+ "errors"
+ "os"
+ "testing"
+
+ "gardomatic.kleiax.de/internal/storage"
+)
+
+func TestTaskModelEnforcesGardenBoundary(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 ('Task integration', $1, 'hash', true) RETURNING id`, "task-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration A') RETURNING id`).Scan(&gardenID); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task 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 := TaskModel{DB: db}
+ local, err := model.Insert(storage.Task{GardenID: gardenID, Title: "Gießen", CreatedBy: userID})
+ if err != nil {
+ t.Fatalf("insert task: %v", err)
+ }
+ foreign, err := model.Insert(storage.Task{GardenID: foreignGardenID, Title: "Fremd", CreatedBy: userID})
+ if err != nil {
+ t.Fatalf("insert foreign task: %v", err)
+ }
+ if _, err := model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
+ t.Fatalf("foreign lookup: got %v", err)
+ }
+ local.Title = "Kräftig gießen"
+ updated, err := model.Update(gardenID, local)
+ if err != nil || updated.Version != 2 {
+ t.Fatalf("update: task=%+v err=%v", updated, err)
+ }
+ if err := model.Delete(foreignGardenID, local.ID); !errors.Is(err, storage.ErrRecordNotFound) {
+ t.Fatalf("foreign delete: got %v", err)
+ }
+ if listed, err := model.GetAllForGarden(gardenID); err != nil || len(listed) != 1 || listed[0].ID != local.ID {
+ t.Fatalf("list: tasks=%+v err=%v", listed, err)
+ }
+}
diff --git a/internal/storage/postgres/tokens.go b/internal/storage/postgres/tokens.go
new file mode 100644
index 0000000..2e4e563
--- /dev/null
+++ b/internal/storage/postgres/tokens.go
@@ -0,0 +1,51 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+)
+
+// TokenModel stores scoped authentication token hashes in PostgreSQL.
+// TokenModel persists only token hashes; plaintext tokens are returned once to
+// the caller and never stored.
+type TokenModel struct {
+ DB *sql.DB
+}
+
+// New creates a token and persists only its hash.
+func (m TokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) {
+ token := auth.NewToken(userID, ttl, scope)
+
+ err := m.insert(token)
+ return token, err
+}
+
+func (m TokenModel) insert(token auth.Token) error {
+ query := `
+ INSERT INTO tokens (hash, user_id, expiry, scope)
+ VALUES ($1, $2, $3, $4)`
+
+ args := []any{token.Hash, token.UserID, token.Expiry, token.Scope}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ _, err := m.DB.ExecContext(ctx, query, args...)
+ return err
+}
+
+// DeleteAllForUser revokes every token for one user and scope.
+func (m TokenModel) DeleteAllForUser(scope string, userID int) error {
+ query := `
+ DELETE FROM tokens
+ WHERE scope = $1 AND user_id = $2`
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ _, err := m.DB.ExecContext(ctx, query, scope, userID)
+ return err
+}
diff --git a/internal/storage/postgres/users.go b/internal/storage/postgres/users.go
new file mode 100644
index 0000000..3006cac
--- /dev/null
+++ b/internal/storage/postgres/users.go
@@ -0,0 +1,424 @@
+package postgres
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "errors"
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "gardomatic.kleiax.de/internal/storage"
+ "github.com/lib/pq"
+)
+
+// UserModel stores user accounts in PostgreSQL.
+// UserModel implements storage.UserModelInterface for PostgreSQL accounts.
+type UserModel struct {
+ DB *sql.DB
+}
+
+// Insert creates a user account.
+func (m UserModel) Insert(user storage.User) (storage.User, error) {
+ query := `
+ INSERT INTO users (name, email, password_hash, activated, application_role)
+ VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM users WHERE deleted_at IS NULL) THEN $5 ELSE $6 END)
+ RETURNING id, created_at, color, application_role, version`
+
+ args := []any{user.Name, user.Email, user.Password.Get(), user.Activated, storage.ApplicationRoleUser, storage.ApplicationRoleAdmin}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return storage.User{}, err
+ }
+ defer tx.Rollback()
+ // Serialize registrations until a first administrator exists. Without this,
+ // two simultaneous registrations could both observe an empty users table.
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(71432026)`); err != nil {
+ return storage.User{}, err
+ }
+ err = tx.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Color, &user.Role, &user.Version)
+ if err != nil {
+ var pqErr *pq.Error
+ switch {
+ case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key":
+ return storage.User{}, storage.ErrDuplicateEmail
+ default:
+ return storage.User{}, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return storage.User{}, err
+ }
+ if err = m.loadPermissions(&user); err != nil {
+ return storage.User{}, err
+ }
+
+ return user, nil
+}
+
+// GetByID returns a non-deleted account with resolved application permissions.
+func (m UserModel) GetByID(id int) (storage.User, error) {
+ query := `
+ SELECT id, created_at, updated_at, name, email, password_hash, activated, color, application_role, version
+ FROM users
+ WHERE id = $1 AND deleted_at IS NULL`
+
+ var user storage.User
+ var pwHash []byte
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ err := m.DB.QueryRowContext(ctx, query, id).Scan(
+ &user.ID,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ &user.Name,
+ &user.Email,
+ &pwHash,
+ &user.Activated,
+ &user.Color,
+ &user.Role,
+ &user.Version,
+ )
+ if err != nil {
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ return storage.User{}, storage.ErrRecordNotFound
+ default:
+ return storage.User{}, err
+ }
+ }
+
+ user.Password = *auth.NewPassword(pwHash)
+ if err = m.loadPermissions(&user); err != nil {
+ return storage.User{}, err
+ }
+
+ return user, nil
+}
+
+// GetByEmail returns a non-deleted account by case-insensitive email.
+func (m UserModel) GetByEmail(email string) (storage.User, error) {
+ query := `
+ SELECT id, created_at, name, email, password_hash, activated, color, application_role, version
+ FROM users
+ WHERE email = $1 AND deleted_at IS NULL`
+
+ var user storage.User
+ var pwHash []byte
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ err := m.DB.QueryRowContext(ctx, query, email).Scan(
+ &user.ID,
+ &user.CreatedAt,
+ &user.Name,
+ &user.Email,
+ &pwHash,
+ &user.Activated,
+ &user.Color,
+ &user.Role,
+ &user.Version,
+ )
+
+ if err != nil {
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ return storage.User{}, storage.ErrRecordNotFound
+ default:
+ return storage.User{}, err
+ }
+ }
+
+ user.Password = *auth.NewPassword(pwHash)
+ if err = m.loadPermissions(&user); err != nil {
+ return storage.User{}, err
+ }
+
+ return user, nil
+}
+
+// Update changes an account using optimistic locking.
+func (m UserModel) Update(user storage.User) (storage.User, error) {
+ query := `
+ UPDATE users
+ SET name = $1, email = $2, password_hash = $3, activated = $4, color = $5,
+ updated_at = CURRENT_TIMESTAMP, version = version + 1
+ WHERE id = $6 AND version = $7 AND deleted_at IS NULL
+ RETURNING updated_at, version`
+
+ args := []any{
+ user.Name,
+ user.Email,
+ user.Password.Get(),
+ user.Activated,
+ user.Color,
+ user.ID,
+ user.Version,
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.UpdatedAt, &user.Version)
+ if err != nil {
+ var pqErr *pq.Error
+ switch {
+ case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key":
+ return storage.User{}, storage.ErrDuplicateEmail
+ case errors.Is(err, sql.ErrNoRows):
+ return storage.User{}, storage.ErrEditConflict
+ default:
+ return storage.User{}, err
+ }
+ }
+
+ return user, nil
+}
+
+// GetForToken resolves a non-expired hashed token to its user.
+func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (storage.User, error) {
+ tokenHash := sha256.Sum256([]byte(tokenPlaintext))
+
+ query := `
+ SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.color, users.application_role, users.version
+ FROM users
+ INNER JOIN tokens
+ ON users.id = tokens.user_id
+ WHERE tokens.hash = $1
+ AND tokens.scope = $2
+ AND tokens.expiry > $3
+ AND users.deleted_at IS NULL`
+
+ args := []any{tokenHash[:], tokenScope, time.Now()}
+
+ var user storage.User
+ var pwHash []byte
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ err := m.DB.QueryRowContext(ctx, query, args...).Scan(
+ &user.ID,
+ &user.CreatedAt,
+ &user.Name,
+ &user.Email,
+ &pwHash,
+ &user.Activated,
+ &user.Color,
+ &user.Role,
+ &user.Version,
+ )
+ if err != nil {
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ return storage.User{}, storage.ErrRecordNotFound
+ default:
+ return storage.User{}, err
+ }
+ }
+
+ user.Password = *auth.NewPassword(pwHash)
+ if err = m.loadPermissions(&user); err != nil {
+ return storage.User{}, err
+ }
+
+ return user, nil
+}
+
+// GetAll lists non-deleted accounts with resolved application permissions.
+func (m UserModel) GetAll() ([]storage.User, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT id, created_at, updated_at, name, email, activated, color, application_role, version FROM users WHERE deleted_at IS NULL ORDER BY name, email, id`)
+ if err != nil {
+ return nil, err
+ }
+ users := []storage.User{}
+ for rows.Next() {
+ var user storage.User
+ if err := rows.Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt, &user.Name, &user.Email, &user.Activated, &user.Color, &user.Role, &user.Version); err != nil {
+ return nil, err
+ }
+ users = append(users, user)
+ }
+ if err = rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err = rows.Close(); err != nil {
+ return nil, err
+ }
+ for i := range users {
+ if err = m.loadPermissions(&users[i]); err != nil {
+ return nil, err
+ }
+ }
+ return users, nil
+}
+
+// UpdateRole changes an account's application role.
+func (m UserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ result, err := m.DB.ExecContext(ctx, `UPDATE users SET application_role=$1, updated_at=CURRENT_TIMESTAMP, version=version+1 WHERE id=$2 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='application')`, role, userID)
+ if err != nil {
+ return storage.User{}, err
+ }
+ count, err := result.RowsAffected()
+ if err != nil {
+ return storage.User{}, err
+ }
+ if count == 0 {
+ return storage.User{}, storage.ErrRecordNotFound
+ }
+ return m.GetByID(userID)
+}
+
+// Delete removes access and personal account data while retaining the user row
+// as an anonymized author for historical garden content.
+// Delete anonymizes an account while preserving authored garden content and
+// removes authentication material and memberships transactionally.
+func (m UserModel) Delete(userID int) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var email string
+ err = tx.QueryRowContext(ctx, `SELECT email FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&email)
+ if err != nil {
+ return recordError(err)
+ }
+ rows, err := tx.QueryContext(ctx, `SELECT garden_id FROM garden_members WHERE user_id=$1 ORDER BY garden_id`, userID)
+ if err != nil {
+ return err
+ }
+ gardenIDs := []int{}
+ for rows.Next() {
+ var gardenID int
+ if err = rows.Scan(&gardenID); err != nil {
+ rows.Close()
+ return err
+ }
+ gardenIDs = append(gardenIDs, gardenID)
+ }
+ if err = rows.Close(); err != nil {
+ return err
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+ for _, gardenID := range gardenIDs {
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
+ return err
+ }
+ }
+ var ownsGarden bool
+ if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM garden_members WHERE user_id=$1 AND role='owner')`, userID).Scan(&ownsGarden); err != nil {
+ return err
+ }
+ if ownsGarden {
+ return storage.ErrConflict
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE user_id=$1`, userID); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM garden_invites WHERE invited_by=$1 OR email=$2`, userID, email); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id=$1`, userID); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil {
+ return err
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE users SET name='Gelöschter Nutzer', email='deleted-' || id::text || '@invalid',
+ activated=false, application_role=$2, deleted_at=now(), updated_at=now(), version=version+1
+ WHERE id=$1 AND deleted_at IS NULL`, userID, storage.ApplicationRoleUser)
+ if err != nil {
+ return err
+ }
+ if count, countErr := result.RowsAffected(); countErr != nil {
+ return countErr
+ } else if count == 0 {
+ return storage.ErrRecordNotFound
+ }
+ return tx.Commit()
+}
+
+func (m UserModel) loadPermissions(user *storage.User) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ rows, err := m.DB.QueryContext(ctx, `SELECT permission FROM role_permissions WHERE role_name=$1 ORDER BY permission`, user.Role)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ user.Permissions = []storage.ApplicationPermission{}
+ for rows.Next() {
+ var permission storage.ApplicationPermission
+ if err := rows.Scan(&permission); err != nil {
+ return err
+ }
+ user.Permissions = append(user.Permissions, permission)
+ }
+ return rows.Err()
+}
+
+// CreateEmailChange stores a pending address and returns its one-time plaintext token.
+func (m UserModel) CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) {
+ plaintext := rand.Text()
+ hash := sha256.Sum256([]byte(plaintext))
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, err := m.DB.ExecContext(ctx, `INSERT INTO user_email_changes (user_id,email,token_hash,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id) DO UPDATE SET email=EXCLUDED.email,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,created_at=now()`, userID, email, hash[:], time.Now().Add(ttl))
+ if err != nil {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) && pqErr.Code == "23505" {
+ return "", storage.ErrDuplicateEmail
+ }
+ return "", err
+ }
+ return plaintext, nil
+}
+
+// ConfirmEmailChange consumes a token and applies its pending address atomically.
+func (m UserModel) ConfirmEmailChange(tokenPlaintext string, expectedUserID int) (storage.User, error) {
+ hash := sha256.Sum256([]byte(tokenPlaintext))
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ tx, err := m.DB.BeginTx(ctx, nil)
+ if err != nil {
+ return storage.User{}, err
+ }
+ defer tx.Rollback()
+ var userID int
+ var email string
+ err = tx.QueryRowContext(ctx, `SELECT user_id,email FROM user_email_changes WHERE token_hash=$1 AND user_id=$2 AND expires_at>now() FOR UPDATE`, hash[:], expectedUserID).Scan(&userID, &email)
+ if err != nil {
+ return storage.User{}, recordError(err)
+ }
+ _, err = tx.ExecContext(ctx, `UPDATE users SET email=$1,updated_at=now(),version=version+1 WHERE id=$2 AND deleted_at IS NULL`, email, userID)
+ if err != nil {
+ return storage.User{}, err
+ }
+ if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil {
+ return storage.User{}, err
+ }
+ if err = tx.Commit(); err != nil {
+ return storage.User{}, err
+ }
+ return m.GetByID(userID)
+}
diff --git a/internal/storage/postgres/users_integration_test.go b/internal/storage/postgres/users_integration_test.go
new file mode 100644
index 0000000..2f3458b
--- /dev/null
+++ b/internal/storage/postgres/users_integration_test.go
@@ -0,0 +1,89 @@
+package postgres
+
+import (
+ "database/sql"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/internal/storage"
+ _ "github.com/lib/pq"
+)
+
+func TestDeleteUserAnonymizesAccountAndPreservesAuthoredContent(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 := strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")
+ originalEmail := "delete-" + stamp + "@example.com"
+ var ownerID, targetID, gardenID, entryID int
+ if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Owner',$1,'hash',true) RETURNING id`, "owner-delete-"+stamp+"@example.com").Scan(&ownerID); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Personal Name',$1,'hash',true) RETURNING id`, originalEmail).Scan(&targetID); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Deletion integration') RETURNING id`).Scan(&gardenID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID)
+ _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1,$2)`, ownerID, targetID)
+ })
+ if _, err = db.Exec(`INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,'owner'),($1,$3,'member')`, gardenID, ownerID, targetID); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.QueryRow(`INSERT INTO journal_entries (garden_id,author_id,title) VALUES ($1,$2,'Bleibt erhalten') RETURNING id`, gardenID, targetID).Scan(&entryID); err != nil {
+ t.Fatal(err)
+ }
+
+ users := UserModel{DB: db}
+ if err = users.Delete(targetID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = users.GetByEmail(originalEmail); !errors.Is(err, storage.ErrRecordNotFound) {
+ t.Fatalf("deleted email still resolves: %v", err)
+ }
+ if _, err = users.GetByID(targetID); !errors.Is(err, storage.ErrRecordNotFound) {
+ t.Fatalf("deleted user ID still resolves: %v", err)
+ }
+ var name, email string
+ var activated bool
+ var deletedAt time.Time
+ if err = db.QueryRow(`SELECT name,email,activated,deleted_at FROM users WHERE id=$1`, targetID).Scan(&name, &email, &activated, &deletedAt); err != nil {
+ t.Fatal(err)
+ }
+ if name != "Gelöschter Nutzer" || email == originalEmail || activated || deletedAt.IsZero() {
+ t.Fatalf("account was not anonymized: name=%q email=%q activated=%t deleted_at=%v", name, email, activated, deletedAt)
+ }
+ var membershipCount, entryCount int
+ if err = db.QueryRow(`SELECT count(*) FROM garden_members WHERE user_id=$1`, targetID).Scan(&membershipCount); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.QueryRow(`SELECT count(*) FROM journal_entries WHERE id=$1 AND author_id=$2`, entryID, targetID).Scan(&entryCount); err != nil {
+ t.Fatal(err)
+ }
+ if membershipCount != 0 || entryCount != 1 {
+ t.Fatalf("membership=%d preserved entries=%d", membershipCount, entryCount)
+ }
+ var replacementID int
+ if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Replacement',$1,'hash',true) RETURNING id`, originalEmail).Scan(&replacementID); err != nil {
+ t.Fatalf("original email was not released: %v", err)
+ }
+ t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, replacementID) })
+ if err = users.Delete(ownerID); !errors.Is(err, storage.ErrConflict) {
+ t.Fatalf("owner deletion error: got %v, want conflict", err)
+ }
+}
diff --git a/internal/storage/roles.go b/internal/storage/roles.go
new file mode 100644
index 0000000..7dc424f
--- /dev/null
+++ b/internal/storage/roles.go
@@ -0,0 +1,47 @@
+package storage
+
+import "time"
+
+// RoleScope separates application-wide privileges from garden membership roles.
+type RoleScope string
+
+// Supported role scopes.
+const (
+ RoleScopeApplication RoleScope = "application"
+ RoleScopeGarden RoleScope = "garden"
+)
+
+// Role is a reusable, globally defined bundle of permissions.
+type Role struct {
+ Name string `json:"name"`
+ Scope RoleScope `json:"scope"`
+ GardenID *int `json:"garden_id,omitempty"`
+ Label string `json:"label"`
+ System bool `json:"system"`
+ Permissions []string `json:"permissions"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// GardenRolePermissionOverride grants or revokes one role permission in a
+// single garden without modifying the shared role template.
+type GardenRolePermissionOverride struct {
+ GardenID int `json:"garden_id"`
+ RoleName string `json:"role_name"`
+ Permission string `json:"permission"`
+ Granted bool `json:"granted"`
+}
+
+// RoleModelInterface manages application roles, garden role templates, and
+// garden-specific permission overrides.
+type RoleModelInterface interface {
+ List(scope RoleScope) ([]Role, error)
+ ListForGarden(gardenID int) ([]Role, error)
+ Get(name string) (Role, error)
+ GetForGarden(gardenID int, name string) (Role, error)
+ Create(role Role) (Role, error)
+ Update(role Role) (Role, error)
+ Delete(name string) error
+ ListGardenOverrides(gardenID int) ([]GardenRolePermissionOverride, error)
+ ReplaceGardenOverrides(gardenID int, roleName string, overrides []GardenRolePermissionOverride) error
+}
diff --git a/internal/storage/species.go b/internal/storage/species.go
new file mode 100644
index 0000000..786f12b
--- /dev/null
+++ b/internal/storage/species.go
@@ -0,0 +1,106 @@
+package storage
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// SpeciesModelInterface persists global and garden-specific species data.
+type SpeciesModelInterface interface {
+ Insert(species Species) (Species, error)
+ Get(gardenID, id int) (Species, error)
+ GetAllForGarden(gardenID int) ([]Species, error)
+ Update(gardenID int, species Species) (Species, error)
+ Delete(gardenID, id int) error
+}
+
+// Species contains reusable botanical and cultivation master data.
+type Species struct {
+ ID int `json:"id"`
+ GardenID *int `json:"garden_id,omitempty"`
+ CommonName string `json:"common_name"`
+ Cultivar string `json:"cultivar"`
+ BotanicalName string `json:"botanical_name"`
+ CategoryID *int `json:"category_id,omitempty"`
+ Category string `json:"category"`
+ SunExposure *string `json:"sun_exposure,omitempty"`
+ SoilCondition *string `json:"soil_condition,omitempty"`
+ SoilReaction *string `json:"soil_reaction,omitempty"`
+ WinterProtection *string `json:"winter_protection,omitempty"`
+ SpacingCM *int `json:"spacing_cm,omitempty"`
+ HeightCM *int `json:"height_cm,omitempty"`
+ SowMonthFrom *int `json:"sow_month_from,omitempty"`
+ SowDayFrom *int `json:"sow_day_from,omitempty"`
+ SowMonthTo *int `json:"sow_month_to,omitempty"`
+ SowDayTo *int `json:"sow_day_to,omitempty"`
+ PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
+ PlantingDayFrom *int `json:"planting_day_from,omitempty"`
+ PlantingMonthTo *int `json:"planting_month_to,omitempty"`
+ PlantingDayTo *int `json:"planting_day_to,omitempty"`
+ HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
+ HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
+ HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
+ HarvestDayTo *int `json:"harvest_day_to,omitempty"`
+ Notes string `json:"notes"`
+ ImageData string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,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"`
+}
+
+// ValidateSpecies applies persistence-independent species validation rules.
+func ValidateSpecies(v *validate.Validator, species Species) {
+ v.Check(strings.TrimSpace(species.CommonName) != "", "common_name", "must be provided")
+ v.Check(len(species.CommonName) <= 500, "common_name", "must not be more than 500 bytes long")
+ v.Check(len(species.Cultivar) <= 500, "cultivar", "must not be more than 500 bytes long")
+ v.Check(len(species.BotanicalName) <= 500, "botanical_name", "must not be more than 500 bytes long")
+ if species.CategoryID != nil {
+ v.Check(*species.CategoryID > 0, "category_id", "must be a positive integer")
+ }
+ v.Check(len(species.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long")
+ v.Check(len(species.Attributes) == 0 || json.Valid(species.Attributes), "attributes", "must be valid JSON")
+ ValidateTags(v, species.Tags)
+ validateOptionalEnum(v, "sun_exposure", species.SunExposure, "sunny", "partial_shade", "shade")
+ validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy")
+ validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
+ validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
+ validateOptionalPositive(v, "height_cm", species.HeightCM)
+ validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12)
+ validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
+ validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
+ validateOptionalCalendarPart(v, "sow_day_to", species.SowDayTo, 31)
+ validateOptionalCalendarPart(v, "planting_month_from", species.PlantingMonthFrom, 12)
+ validateOptionalCalendarPart(v, "planting_day_from", species.PlantingDayFrom, 31)
+ validateOptionalCalendarPart(v, "planting_month_to", species.PlantingMonthTo, 12)
+ validateOptionalCalendarPart(v, "planting_day_to", species.PlantingDayTo, 31)
+ validateOptionalCalendarPart(v, "harvest_month_from", species.HarvestMonthFrom, 12)
+ validateOptionalCalendarPart(v, "harvest_day_from", species.HarvestDayFrom, 31)
+ validateOptionalCalendarPart(v, "harvest_month_to", species.HarvestMonthTo, 12)
+ validateOptionalCalendarPart(v, "harvest_day_to", species.HarvestDayTo, 31)
+}
+
+func validateOptionalEnum(v *validate.Validator, field string, value *string, allowed ...string) {
+ if value != nil {
+ v.Check(validate.PermittedValue(*value, allowed...), field, "is invalid")
+ }
+}
+
+func validateOptionalPositive(v *validate.Validator, field string, value *int) {
+ if value != nil {
+ v.Check(*value > 0, field, "must be greater than zero")
+ }
+}
+
+func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
+ if value != nil {
+ v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
+ }
+}
diff --git a/internal/storage/species_categories.go b/internal/storage/species_categories.go
new file mode 100644
index 0000000..de6b3fb
--- /dev/null
+++ b/internal/storage/species_categories.go
@@ -0,0 +1,35 @@
+package storage
+
+import (
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// SpeciesCategoryModelInterface persists the configurable species taxonomy.
+type SpeciesCategoryModelInterface interface {
+ Insert(category SpeciesCategory) (SpeciesCategory, error)
+ Get(id int) (SpeciesCategory, error)
+ GetAll() ([]SpeciesCategory, error)
+ Update(category SpeciesCategory) (SpeciesCategory, error)
+}
+
+// SpeciesCategory is a globally managed option for classifying species.
+type SpeciesCategory struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ SortOrder int `json:"sort_order"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+ Lifecycle *string `json:"lifecycle,omitempty"`
+}
+
+// ValidateSpeciesCategory applies validation rules independent of persistence.
+func ValidateSpeciesCategory(v *validate.Validator, category SpeciesCategory) {
+ v.Check(strings.TrimSpace(category.Name) != "", "name", "must be provided")
+ v.Check(len(category.Name) <= 200, "name", "must not be more than 200 bytes long")
+ validateOptionalEnum(v, "lifecycle", category.Lifecycle, "annual", "biennial", "perennial")
+}
diff --git a/internal/storage/species_task_templates.go b/internal/storage/species_task_templates.go
new file mode 100644
index 0000000..e8524c3
--- /dev/null
+++ b/internal/storage/species_task_templates.go
@@ -0,0 +1,117 @@
+package storage
+
+import (
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// TaskTriggerType identifies how a species task template calculates its due window.
+type TaskTriggerType string
+
+// TaskDurationUnit is the calendar unit used for offsets and durations.
+type TaskDurationUnit string
+
+// TaskTemplateOrigin identifies whether a template was entered manually or derived from species data.
+type TaskTemplateOrigin string
+
+// Supported task duration units.
+const (
+ TaskDurationDay TaskDurationUnit = "day"
+ TaskDurationWeek TaskDurationUnit = "week"
+ TaskDurationMonth TaskDurationUnit = "month"
+)
+
+// Supported task trigger types.
+const (
+ // TaskTriggerMonthOfYear repeats within a calendar-year date window.
+ TaskTriggerMonthOfYear TaskTriggerType = "month_of_year"
+ // TaskTriggerRelativeToPlanting is offset from the planting date.
+ TaskTriggerRelativeToPlanting TaskTriggerType = "relative_to_planting"
+ // TaskTriggerRelativeToLastTask is offset from the last completion.
+ TaskTriggerRelativeToLastTask TaskTriggerType = "relative_to_last_task"
+ TaskTriggerRelativeToSowing TaskTriggerType = "relative_to_sowing"
+ TaskTriggerRelativeToHarvest TaskTriggerType = "relative_to_harvest"
+ TaskTriggerRelativeToSpeciesPlanting TaskTriggerType = "relative_to_species_planting"
+)
+
+// Supported template origins.
+const (
+ TaskTemplateOriginManual TaskTemplateOrigin = "manual"
+ TaskTemplateOriginSeasonSowing TaskTemplateOrigin = "season_sowing"
+ TaskTemplateOriginSeasonPlanting TaskTemplateOrigin = "season_planting"
+ TaskTemplateOriginSeasonHarvest TaskTemplateOrigin = "season_harvest"
+)
+
+// SpeciesTaskTemplateModelInterface persists recurring rules for species tasks.
+type SpeciesTaskTemplateModelInterface interface {
+ Insert(template SpeciesTaskTemplate) (SpeciesTaskTemplate, error)
+ Get(gardenID, id int) (SpeciesTaskTemplate, error)
+ GetAllForSpecies(gardenID, speciesID int) ([]SpeciesTaskTemplate, error)
+ Update(gardenID int, template SpeciesTaskTemplate) (SpeciesTaskTemplate, error)
+ Delete(gardenID, id int) error
+}
+
+// ValidateSpeciesTaskTemplate applies scheduling and recurrence rules to a
+// species task template.
+func ValidateSpeciesTaskTemplate(v *validate.Validator, template SpeciesTaskTemplate) {
+ v.Check(strings.TrimSpace(template.Title) != "", "title", "must be provided")
+ v.Check(len(template.Title) <= 500, "title", "must not be more than 500 bytes long")
+ v.Check(len(template.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
+ v.Check(template.Priority >= -100 && template.Priority <= 100, "priority", "must be between -100 and 100")
+ v.Check(template.TriggerType == TaskTriggerMonthOfYear || template.TriggerType == TaskTriggerRelativeToPlanting || template.TriggerType == TaskTriggerRelativeToLastTask || template.TriggerType == TaskTriggerRelativeToSowing || template.TriggerType == TaskTriggerRelativeToHarvest || template.TriggerType == TaskTriggerRelativeToSpeciesPlanting, "trigger_type", "is invalid")
+ v.Check(template.Origin == TaskTemplateOriginManual || template.Origin == TaskTemplateOriginSeasonSowing || template.Origin == TaskTemplateOriginSeasonPlanting || template.Origin == TaskTemplateOriginSeasonHarvest, "origin", "is invalid")
+ if template.TriggerType == TaskTriggerMonthOfYear {
+ v.Check(template.MonthFrom != nil && *template.MonthFrom >= 1 && *template.MonthFrom <= 12, "month_from", "must be between 1 and 12")
+ validateTemplateDay(v, "day_from", template.DayFrom)
+ v.Check(template.DayFrom != nil, "day_from", "must be provided")
+ } else {
+ v.Check(template.TriggerOffset >= 0, "trigger_offset", "must not be negative")
+ }
+ v.Check(template.Duration >= 0, "duration", "must not be negative")
+ v.Check(validTaskDurationUnit(template.DurationUnit), "duration_unit", "is invalid")
+ v.Check(validTaskDurationUnit(template.TriggerOffsetUnit), "trigger_offset_unit", "is invalid")
+ v.Check(template.Recurrence == TaskRecurrenceNone || template.Recurrence == TaskRecurrenceDaily || template.Recurrence == TaskRecurrenceWeekly || template.Recurrence == TaskRecurrenceMonthly || template.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid")
+ if template.Recurrence != TaskRecurrenceNone {
+ v.Check(template.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero")
+ }
+}
+
+func validTaskDurationUnit(unit TaskDurationUnit) bool {
+ return unit == TaskDurationDay || unit == TaskDurationWeek || unit == TaskDurationMonth
+}
+
+func validateTemplateDay(v *validate.Validator, field string, value *int) {
+ if value != nil {
+ v.Check(*value >= 1 && *value <= 31, field, "must be between 1 and 31")
+ }
+}
+
+// SpeciesTaskTemplate defines a recurring task rule for a species.
+type SpeciesTaskTemplate struct {
+ ID int `json:"id"`
+ SpeciesID int `json:"species_id"`
+ Origin TaskTemplateOrigin `json:"origin"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ TriggerType TaskTriggerType `json:"trigger_type"`
+ MonthFrom *int `json:"month_from,omitempty"`
+ DayFrom *int `json:"day_from,omitempty"`
+ MonthTo *int `json:"month_to,omitempty"`
+ DayTo *int `json:"day_to,omitempty"`
+ OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
+ OffsetDaysTo *int `json:"offset_days_to,omitempty"`
+ IntervalDays *int `json:"interval_days,omitempty"`
+ TriggerOffset int `json:"trigger_offset"`
+ TriggerOffsetUnit TaskDurationUnit `json:"trigger_offset_unit"`
+ Duration int `json:"duration"`
+ DurationUnit TaskDurationUnit `json:"duration_unit"`
+ Recurrence TaskRecurrence `json:"recurrence,omitempty"`
+ RecurrenceInterval int `json:"recurrence_interval"`
+ Priority int `json:"priority"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+}
diff --git a/internal/storage/tags.go b/internal/storage/tags.go
new file mode 100644
index 0000000..b6de087
--- /dev/null
+++ b/internal/storage/tags.go
@@ -0,0 +1,50 @@
+package storage
+
+import (
+ "sort"
+ "strings"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// TagEntity identifies a resource type that can be tagged.
+type TagEntity string
+
+// Supported taggable entity types.
+const (
+ TagEntityTask TagEntity = "task"
+ TagEntityPlant TagEntity = "plant"
+ TagEntitySpecies TagEntity = "species"
+ TagEntityJournal TagEntity = "journal_entry"
+)
+
+// TagModelInterface lists tags and atomically replaces an entity's tag set.
+type TagModelInterface interface {
+ Get(gardenID int, entity TagEntity, entityID int) ([]string, error)
+ Set(gardenID int, entity TagEntity, entityID int, tags []string) ([]string, error)
+ GetAllForGarden(gardenID int) ([]string, error)
+}
+
+// NormalizeTags trims, removes empty values, and deduplicates tags while
+// preserving their first occurrence.
+func NormalizeTags(values []string) []string {
+ seen := map[string]bool{}
+ result := []string{}
+ for _, value := range values {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value != "" && !seen[value] {
+ seen[value] = true
+ result = append(result, value)
+ }
+ }
+ sort.Strings(result)
+ return result
+}
+
+// ValidateTags applies count and length limits to a normalized tag list.
+func ValidateTags(v *validate.Validator, tags []string) {
+ v.Check(len(tags) <= 20, "tags", "must contain at most 20 tags")
+ for _, tag := range tags {
+ v.Check(len(tag) <= 50, "tags", "each tag must contain at most 50 bytes")
+ }
+}
diff --git a/internal/storage/task_priorities.go b/internal/storage/task_priorities.go
new file mode 100644
index 0000000..3c405d5
--- /dev/null
+++ b/internal/storage/task_priorities.go
@@ -0,0 +1,35 @@
+package storage
+
+import (
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// TaskPriorityModelInterface manages the application-wide priority catalogue.
+type TaskPriorityModelInterface interface {
+ Insert(priority TaskPriority) (TaskPriority, error)
+ Get(id int) (TaskPriority, error)
+ GetAll() ([]TaskPriority, error)
+ Update(priority TaskPriority) (TaskPriority, error)
+}
+
+// TaskPriority maps a user-facing label to the numeric value stored on tasks.
+type TaskPriority struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Value int `json:"value"`
+ SortOrder int `json:"sort_order"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+}
+
+// ValidateTaskPriority applies catalogue-entry validation rules.
+func ValidateTaskPriority(v *validate.Validator, priority TaskPriority) {
+ v.Check(strings.TrimSpace(priority.Name) != "", "name", "must be provided")
+ v.Check(len(priority.Name) <= 100, "name", "must not be more than 100 bytes long")
+ v.Check(priority.Value >= -100 && priority.Value <= 100, "value", "must be between -100 and 100")
+}
diff --git a/internal/storage/task_template_opt_outs.go b/internal/storage/task_template_opt_outs.go
new file mode 100644
index 0000000..26fdb25
--- /dev/null
+++ b/internal/storage/task_template_opt_outs.go
@@ -0,0 +1,9 @@
+package storage
+
+// TaskTemplateOptOutModelInterface records per-plant suppression of automatic
+// task generation from a species template.
+type TaskTemplateOptOutModelInterface interface {
+ IsOptedOut(gardenID, plantID, templateID int) (bool, error)
+ GetAllForPlant(gardenID, plantID int) ([]int, error)
+ Set(gardenID, plantID, templateID int, optedOut bool) error
+}
diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go
new file mode 100644
index 0000000..67d71c2
--- /dev/null
+++ b/internal/storage/tasks.go
@@ -0,0 +1,88 @@
+package storage
+
+import (
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// TaskRecurrence identifies the calendar interval of a manual task.
+type TaskRecurrence string
+
+// Supported task recurrence values. An empty value disables recurrence.
+const (
+ TaskRecurrenceNone TaskRecurrence = ""
+ TaskRecurrenceDaily TaskRecurrence = "daily"
+ TaskRecurrenceWeekly TaskRecurrence = "weekly"
+ TaskRecurrenceMonthly TaskRecurrence = "monthly"
+ TaskRecurrenceYearly TaskRecurrence = "yearly"
+)
+
+// TaskModelInterface persists garden work items and generated task instances.
+type TaskModelInterface interface {
+ Insert(task Task) (Task, error)
+ Get(gardenID, id int) (Task, error)
+ GetAllForGarden(gardenID int) ([]Task, error)
+ Update(gardenID int, task Task) (Task, error)
+ Delete(gardenID, id int) error
+}
+
+// ValidateTask applies persistence-independent rules for manual garden tasks.
+func ValidateTask(v *validate.Validator, task Task) {
+ v.Check(strings.TrimSpace(task.Title) != "", "title", "must be provided")
+ v.Check(len(task.Title) <= 500, "title", "must not be more than 500 bytes long")
+ v.Check(len(task.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
+ v.Check(task.Priority >= -100 && task.Priority <= 100, "priority", "must be between -100 and 100")
+ if task.PlantID != nil {
+ v.Check(*task.PlantID > 0, "plant_id", "must be a positive integer")
+ }
+ if task.LocationID != nil {
+ v.Check(*task.LocationID > 0, "location_id", "must be a positive integer")
+ }
+ if task.DueAtStart != nil && task.DueAtEnd != nil {
+ v.Check(!task.DueAtStart.After(*task.DueAtEnd), "due_at_end", "must not be before due_at_start")
+ }
+ v.Check(task.Recurrence == TaskRecurrenceNone || task.Recurrence == TaskRecurrenceDaily || task.Recurrence == TaskRecurrenceWeekly || task.Recurrence == TaskRecurrenceMonthly || task.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid")
+ if task.Recurrence != TaskRecurrenceNone {
+ v.Check(task.DueAtStart != nil || task.DueAtEnd != nil, "recurrence", "requires a due date")
+ v.Check(task.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero")
+ }
+ validateOptionalEnum(v, "plant_status_on_completion", task.PlantStatusOnCompletion, "alive", "dead", "removed", "infested", "harvested")
+ if task.PlantStatusOnCompletion != nil {
+ v.Check(task.PlantID != nil, "plant_status_on_completion", "requires a plant")
+ }
+ if task.CompletedAt != nil {
+ v.Check(task.CompletedBy != nil && *task.CompletedBy > 0, "completed_by", "must be set for a completed task")
+ }
+ if task.CompletedBy != nil {
+ v.Check(task.CompletedAt != nil, "completed_at", "must be set when completed_by is set")
+ }
+}
+
+// Task represents a manual or template-generated garden work item.
+type Task struct {
+ ID int `json:"id"`
+ GardenID int `json:"garden_id"`
+ PlantID *int `json:"plant_id,omitempty"`
+ LocationID *int `json:"location_id,omitempty"`
+ TemplateID *int `json:"template_id,omitempty"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DueAtStart *time.Time `json:"due_at_start,omitempty"`
+ DueAtEnd *time.Time `json:"due_at_end,omitempty"`
+ GeneratedFor *time.Time `json:"generated_for,omitempty"`
+ Recurrence TaskRecurrence `json:"recurrence,omitempty"`
+ RecurrenceInterval int `json:"recurrence_interval"`
+ RepeatFromID *int `json:"repeat_from_id,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ CompletedBy *int `json:"completed_by,omitempty"`
+ Priority int `json:"priority"`
+ Active bool `json:"active"`
+ CreatedBy int `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+ Tags []string `json:"tags,omitempty"`
+ PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
+}
diff --git a/internal/storage/tokens.go b/internal/storage/tokens.go
new file mode 100644
index 0000000..eb65dc8
--- /dev/null
+++ b/internal/storage/tokens.go
@@ -0,0 +1,13 @@
+package storage
+
+import (
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+)
+
+// TokenModelInterface persists scoped, expiring authentication token hashes.
+type TokenModelInterface interface {
+ New(userID int, ttl time.Duration, scope string) (auth.Token, error)
+ DeleteAllForUser(scope string, userID int) error
+}
diff --git a/internal/storage/users.go b/internal/storage/users.go
new file mode 100644
index 0000000..294d7dd
--- /dev/null
+++ b/internal/storage/users.go
@@ -0,0 +1,141 @@
+package storage
+
+import (
+ "time"
+
+ "gardomatic.kleiax.de/internal/auth"
+ "gardomatic.kleiax.de/internal/platform/validate"
+)
+
+// UserModelInterface persists accounts and resolves token ownership.
+type UserModelInterface interface {
+ Insert(user User) (User, error)
+ GetByID(id int) (User, error)
+ GetByEmail(email string) (User, error)
+ Update(user User) (User, error)
+ GetForToken(tokenScope, tokenPlaintext string) (User, error)
+ CreateEmailChange(userID int, email string, ttl time.Duration) (string, error)
+ ConfirmEmailChange(tokenPlaintext string, userID int) (User, error)
+ GetAll() ([]User, error)
+ UpdateRole(userID int, role ApplicationRole) (User, error)
+ Delete(userID int) error
+}
+
+// ApplicationRole groups garden-independent permissions for an account.
+type ApplicationRole string
+
+// ApplicationPermission identifies one garden-independent capability.
+type ApplicationPermission string
+
+// Built-in application roles and permissions.
+const (
+ ApplicationRoleUser ApplicationRole = "application:user"
+ ApplicationRoleAdmin ApplicationRole = "application:admin"
+
+ ApplicationPermissionGlobalSpeciesWrite ApplicationPermission = "global_species:write"
+ ApplicationPermissionUsersManage ApplicationPermission = "users:manage"
+ ApplicationPermissionSettingsWrite ApplicationPermission = "application_settings:write"
+ ApplicationPermissionRolesManage ApplicationPermission = "roles:manage"
+ ApplicationPermissionGardensCreate ApplicationPermission = "gardens:create"
+)
+
+// Valid reports whether role is assignable to an account.
+func (role ApplicationRole) Valid() bool {
+ return role == ApplicationRoleUser || role == ApplicationRoleAdmin
+}
+
+// Can reports whether an application role grants a permission.
+func (role ApplicationRole) Can(permission ApplicationPermission) bool {
+ if permission == ApplicationPermissionGardensCreate {
+ return role == ApplicationRoleUser || role == ApplicationRoleAdmin
+ }
+ return role == ApplicationRoleAdmin && (permission == ApplicationPermissionGlobalSpeciesWrite ||
+ permission == ApplicationPermissionUsersManage ||
+ permission == ApplicationPermissionSettingsWrite ||
+ permission == ApplicationPermissionRolesManage)
+}
+
+// Permissions returns the capabilities bundled into the role.
+func (role ApplicationRole) Permissions() []ApplicationPermission {
+ permissions := []ApplicationPermission{}
+ for _, permission := range []ApplicationPermission{
+ ApplicationPermissionGlobalSpeciesWrite,
+ ApplicationPermissionUsersManage,
+ ApplicationPermissionSettingsWrite,
+ ApplicationPermissionRolesManage,
+ ApplicationPermissionGardensCreate,
+ } {
+ if role.Can(permission) {
+ permissions = append(permissions, permission)
+ }
+ }
+ return permissions
+}
+
+// AllApplicationPermissions returns a copy of the supported application-level
+// permissions.
+func AllApplicationPermissions() []ApplicationPermission {
+ return []ApplicationPermission{
+ ApplicationPermissionGlobalSpeciesWrite,
+ ApplicationPermissionUsersManage,
+ ApplicationPermissionSettingsWrite,
+ ApplicationPermissionRolesManage,
+ ApplicationPermissionGardensCreate,
+ }
+}
+
+// ValidApplicationPermission reports whether permission is application-scoped
+// or the global wildcard.
+func ValidApplicationPermission(permission string) bool {
+ for _, candidate := range AllApplicationPermissions() {
+ if string(candidate) == permission {
+ return true
+ }
+ }
+ return permission == "*"
+}
+
+// User is an account that can own or join gardens.
+type User struct {
+ ID int `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Color string `json:"color"`
+ Password auth.Password `json:"-"`
+ Activated bool `json:"activated"`
+ Role ApplicationRole `json:"role"`
+ Permissions []ApplicationPermission `json:"permissions"`
+ Version int `json:"-"`
+}
+
+// Can reports whether the user's resolved permissions grant permission. It
+// falls back to the built-in role only when no resolved list is present.
+func (user User) Can(permission ApplicationPermission) bool {
+ if user.Permissions != nil {
+ for _, granted := range user.Permissions {
+ if granted == permission || granted == "*" {
+ return true
+ }
+ }
+ return false
+ }
+ return user.Role.Can(permission)
+}
+
+// ValidateEmail applies the accepted email-address rules.
+func ValidateEmail(v *validate.Validator, email string) {
+ v.Check(email != "", "email", "must be provided")
+ v.Check(validate.Matches(email, validate.EmailRX), "email", "must be a valid email address")
+}
+
+// ValidateUser applies account and password validation rules.
+func ValidateUser(v *validate.Validator, user User) {
+ v.Check(user.Name != "", "name", "must be provided")
+ v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long")
+
+ ValidateEmail(v, user.Email)
+ v.Check(user.Color == "" || validate.Matches(user.Color, validate.ColorRX), "color", "must be a valid hex color")
+ user.Password.Validate(v)
+}
diff --git a/internal/vcs/doc.go b/internal/vcs/doc.go
new file mode 100644
index 0000000..c35dd83
--- /dev/null
+++ b/internal/vcs/doc.go
@@ -0,0 +1,2 @@
+// Package vcs derives build version information from Go build metadata.
+package vcs
diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go
new file mode 100644
index 0000000..437f3bd
--- /dev/null
+++ b/internal/vcs/vcs.go
@@ -0,0 +1,15 @@
+package vcs
+
+import (
+ "runtime/debug"
+)
+
+// Version returns the main module version from Go build metadata.
+func Version() string {
+ bi, ok := debug.ReadBuildInfo()
+ if ok {
+ return bi.Main.Version
+ }
+
+ return ""
+}
diff --git a/internal/web/account.go b/internal/web/account.go
new file mode 100644
index 0000000..c68d8c5
--- /dev/null
+++ b/internal/web/account.go
@@ -0,0 +1,165 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "gardomatic.kleiax.de/lib/client"
+ "github.com/julienschmidt/httprouter"
+)
+
+type accountForm struct {
+ CSRFToken string `form:"csrf_token"`
+ Name string `form:"name"`
+ Color string `form:"color"`
+ Email string `form:"email"`
+ CurrentPassword string `form:"current_password"`
+ NewPassword string `form:"new_password"`
+ NewPasswordConfirmation string `form:"new_password_confirmation"`
+ Errors map[string]string
+ Message string
+}
+
+func (app *application) account(w http.ResponseWriter, r *http.Request) {
+ user, _ := userFromContext(r.Context())
+ app.renderAccount(w, r, accountForm{Name: user.Name, Color: user.Color, Email: user.Email, Errors: map[string]string{}}, http.StatusOK)
+}
+
+func (app *application) accountSessionDeletePost(w http.ResponseWriter, r *http.Request) {
+ id := httprouter.ParamsFromContext(r.Context()).ByName("sessionID")
+ response, err := client.FromContext(r.Context()).DeleteAccountSession(r.Context(), id)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ client.ForwardCookies(w, response)
+ http.Redirect(w, r, webPath("account")+gardenQuerySuffix(r), http.StatusSeeOther)
+}
+func (app *application) accountProfilePost(w http.ResponseWriter, r *http.Request) {
+ var form accountForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ form.Name = strings.TrimSpace(form.Name)
+ form.Color = strings.TrimSpace(form.Color)
+ form.Errors = map[string]string{}
+ if form.Name == "" {
+ form.Errors["name"] = "Ein Name ist erforderlich."
+ }
+ if len(form.Errors) == 0 {
+ user, _, err := client.FromContext(r.Context()).UpdateAccountProfile(r.Context(), form.Name, form.Color)
+ if err == nil {
+ form.Name, form.Color, form.Email, form.Message = user.Name, user.Color, user.Email, "Profil gespeichert."
+ app.renderAccount(w, r, form, http.StatusOK)
+ return
+ }
+ app.copyAccountError(&form, err)
+ if len(form.Errors) == 0 {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ }
+ app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
+}
+func (app *application) accountPasswordPost(w http.ResponseWriter, r *http.Request) {
+ var form accountForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ user, _ := userFromContext(r.Context())
+ form.Name, form.Color, form.Email, form.Errors = user.Name, user.Color, user.Email, map[string]string{}
+ if form.NewPassword != form.NewPasswordConfirmation {
+ form.Errors["new_password_confirmation"] = "Die Passwörter stimmen nicht überein."
+ }
+ if len(form.NewPassword) < 8 {
+ form.Errors["new_password"] = "Mindestens 8 Zeichen erforderlich."
+ }
+ if len(form.Errors) == 0 {
+ _, err := client.FromContext(r.Context()).UpdateAccountPassword(r.Context(), form.CurrentPassword, form.NewPassword)
+ if err == nil {
+ form.CurrentPassword, form.NewPassword, form.NewPasswordConfirmation = "", "", ""
+ form.Message = "Passwort geändert."
+ app.renderAccount(w, r, form, http.StatusOK)
+ return
+ }
+ app.copyAccountError(&form, err)
+ if len(form.Errors) == 0 {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ }
+ app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
+}
+func (app *application) accountEmailPost(w http.ResponseWriter, r *http.Request) {
+ var form accountForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ user, _ := userFromContext(r.Context())
+ form.Name, form.Color, form.Errors = user.Name, user.Color, map[string]string{}
+ form.Email = strings.TrimSpace(form.Email)
+ if form.Email == "" {
+ form.Errors["email"] = "Eine E-Mail-Adresse ist erforderlich."
+ }
+ if len(form.Errors) == 0 {
+ _, err := client.FromContext(r.Context()).RequestAccountEmailChange(r.Context(), form.Email, form.CurrentPassword)
+ if err == nil {
+ form.CurrentPassword = ""
+ form.Message = "Bestätigungslink wurde an die neue Adresse gesendet."
+ app.renderAccount(w, r, form, http.StatusOK)
+ return
+ }
+ app.copyAccountError(&form, err)
+ if len(form.Errors) == 0 {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ }
+ app.renderAccount(w, r, form, http.StatusUnprocessableEntity)
+}
+func (app *application) accountEmailConfirm(w http.ResponseWriter, r *http.Request) {
+ data := app.newTemplateData(r)
+ data.Form = acceptInviteForm{Token: r.URL.Query().Get("token")}
+ app.render(w, http.StatusOK, "email_confirm.tmpl", data)
+}
+func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.Request) {
+ var form acceptInviteForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ if _, _, err := client.FromContext(r.Context()).ConfirmAccountEmail(r.Context(), form.Token); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, webPath("account"), http.StatusSeeOther)
+}
+func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
+ data := app.newTemplateData(r)
+ data.Form = form
+ if !app.loadOptionalGarden(w, r, data) {
+ return
+ }
+ sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ data.AccountSessions = sessions
+ app.render(w, status, "account.tmpl", data)
+}
+func (app *application) copyAccountError(form *accountForm, err error) {
+ var apiError *client.APIError
+ if errors.As(err, &apiError) {
+ if apiError.StatusCode == http.StatusUnprocessableEntity {
+ form.Errors = apiError.Validation
+ }
+ if apiError.StatusCode == http.StatusUnauthorized {
+ form.Errors["current_password"] = "Das aktuelle Passwort ist falsch."
+ }
+ }
+}
diff --git a/internal/web/admin.go b/internal/web/admin.go
new file mode 100644
index 0000000..c2db761
--- /dev/null
+++ b/internal/web/admin.go
@@ -0,0 +1,416 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+type adminRoleForm struct {
+ Role string `form:"role"`
+}
+
+type adminUserInviteForm struct {
+ Name string `form:"name"`
+ Email string `form:"email"`
+}
+
+type roleSettingsForm struct {
+ Name string `form:"name"`
+ Scope string `form:"scope"`
+ Label string `form:"label"`
+ Permissions []string `form:"permissions"`
+}
+
+func applicationPermissionOptions() []permissionOption {
+ return []permissionOption{{"gardens:create", "Gärten anlegen"}, {"global_species:write", "Globale Pflanzen verwalten"}, {"users:manage", "Nutzerrollen verwalten"}, {"application_settings:write", "Instanzeinstellungen verwalten"}, {"roles:manage", "Rollen verwalten"}}
+}
+
+func gardenPermissionOptions() []permissionOption {
+ return []permissionOption{
+ {"garden:read", "Garten sehen"}, {"garden:update", "Garten bearbeiten"}, {"garden:delete", "Garten löschen"}, {"members:write", "Mitglieder verwalten"}, {"species:write", "Pflanzenarten verwalten"}, {"content:write", "Tagebuch und Zuordnungen bearbeiten"},
+ {"plants:create", "Pflanzen anlegen"}, {"plants:read:own", "Eigene Pflanzen sehen"}, {"plants:read:other", "Andere Pflanzen sehen"}, {"plants:update:own", "Eigene Pflanzen bearbeiten"}, {"plants:update:other", "Andere Pflanzen bearbeiten"}, {"plants:delete:own", "Eigene Pflanzen löschen"}, {"plants:delete:other", "Andere Pflanzen löschen"},
+ {"locations:create", "Orte anlegen"}, {"locations:read:own", "Eigene Orte sehen"}, {"locations:read:other", "Andere Orte sehen"}, {"locations:update:own", "Eigene Orte bearbeiten"}, {"locations:update:other", "Andere Orte bearbeiten"}, {"locations:delete:own", "Eigene Orte löschen"}, {"locations:delete:other", "Andere Orte löschen"},
+ {"tasks:create", "Aufgaben anlegen"}, {"tasks:read:own", "Eigene Aufgaben sehen"}, {"tasks:read:other", "Andere Aufgaben sehen"}, {"tasks:update:own", "Eigene Aufgaben bearbeiten"}, {"tasks:update:other", "Andere Aufgaben bearbeiten"}, {"tasks:delete:own", "Eigene Aufgaben löschen"}, {"tasks:delete:other", "Andere Aufgaben löschen"}, {"tasks:complete:own", "Eigene Aufgaben erledigen"}, {"tasks:complete:other", "Andere Aufgaben erledigen"},
+ }
+}
+
+type adminSpeciesCategoryForm struct {
+ Name string `form:"name"`
+ SortOrder int `form:"sort_order"`
+ Active bool `form:"active"`
+ Lifecycle string `form:"lifecycle"`
+}
+
+type adminTaskPriorityForm struct {
+ Name string `form:"name"`
+ Value int `form:"value"`
+ SortOrder int `form:"sort_order"`
+ Active bool `form:"active"`
+}
+
+type adminApplicationSettingsForm struct {
+ LifecycleStatusEnabled bool `form:"lifecycle_status_enabled"`
+ LifecycleRemovalMonth int `form:"lifecycle_removal_month"`
+ LifecycleRemovalDay int `form:"lifecycle_removal_day"`
+ Timezone string `form:"timezone"`
+}
+
+type adminTestMailForm struct {
+ Email string `form:"email"`
+}
+
+func (app *application) requireAdmin(next http.Handler) http.Handler {
+ return app.requireActivatedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok || !user.IsAdmin() {
+ app.clientError(w, http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ }))
+}
+
+func (app *application) requireApplicationPermission(permission string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok || !user.Can(permission) {
+ app.clientError(w, http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (app *application) admin(w http.ResponseWriter, r *http.Request) {
+ apiClient := client.FromContext(r.Context())
+ users, _, err := apiClient.AdminUsers(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ categories, _, err := client.FromContext(r.Context()).AdminSpeciesCategories(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ priorities, _, err := client.FromContext(r.Context()).AdminTaskPriorities(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ settings, _, err := client.FromContext(r.Context()).AdminApplicationSettings(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ environmentVariables, _, err := apiClient.AdminEnvironment(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ applicationRoles, gardenRoles, _, err := apiClient.AdminRoles(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ data := app.newTemplateData(r)
+ if !app.loadOptionalGarden(w, r, data) {
+ return
+ }
+ data.AdminUsers, data.SpeciesCategories, data.TaskPriorities = users, categories, priorities
+ data.ApplicationRoles, data.GardenRoles = applicationRoles, gardenRoles
+ data.ApplicationPermissions, data.GardenPermissions = applicationPermissionOptions(), gardenPermissionOptions()
+ data.RoleEditors = []roleEditorData{
+ globalRoleEditor("roles-settings", "Instanzrollen", "Diese Rollen gelten für die gesamte Serverinstanz.", "application", applicationRoles, data.ApplicationPermissions, data.CSRFToken),
+ globalRoleEditor("garden-role-templates", "Gartenrollen", "Diese Vorlagen gelten in allen Gärten und können je Garten überschrieben werden.", "garden", gardenRoles, data.GardenPermissions, data.CSRFToken),
+ }
+ for i := range data.RoleEditors {
+ data.RoleEditors[i].Garden = data.Garden
+ }
+ data.ApplicationSettings = &settings
+ data.EnvironmentVariables = append(environmentVariables, app.webEnvironmentVariables()...)
+ if r.URL.Query().Get("test-mail") == "sent" {
+ data.Flash = "Die Testmail wurde versendet."
+ }
+ if r.URL.Query().Get("test-mail") == "invalid" {
+ data.Flash = "Bitte gib eine gültige Empfängeradresse für die Testmail ein."
+ }
+ if r.URL.Query().Get("invitation") == "sent" {
+ data.Flash = "Die Einladung wurde versendet."
+ }
+ if r.URL.Query().Get("invitation") == "duplicate" {
+ data.Flash = "Für diese E-Mail-Adresse existiert bereits ein aktiver Account."
+ }
+ if r.URL.Query().Get("invitation") == "invalid" {
+ data.Flash = "Die Einladung ist ungültig. Bitte prüfe Name und E-Mail-Adresse."
+ }
+ if r.URL.Query().Get("deletion") == "done" {
+ data.Flash = "Der Nutzer wurde gelöscht und seine Kontodaten wurden anonymisiert."
+ }
+ if r.URL.Query().Get("deletion") == "owner" {
+ data.Flash = "Der Nutzer besitzt noch mindestens einen Garten. Übertrage zuerst das Eigentum."
+ }
+ app.render(w, http.StatusOK, "admin.tmpl", data)
+}
+
+func (app *application) adminUserInvitePost(w http.ResponseWriter, r *http.Request) {
+ var form adminUserInviteForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ input := client.AdminUserInviteInput{Name: strings.TrimSpace(form.Name), Email: strings.TrimSpace(form.Email)}
+ if _, _, err := client.FromContext(r.Context()).InviteAdminUser(r.Context(), input); err != nil {
+ var apiError *client.APIError
+ if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
+ status := "invalid"
+ if apiError.Validation["email"] == "a user with this email address already exists" {
+ status = "duplicate"
+ }
+ http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", status), http.StatusSeeOther)
+ return
+ }
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", "sent"), http.StatusSeeOther)
+}
+
+func (app *application) adminUserDeletePost(w http.ResponseWriter, r *http.Request) {
+ userID, err := app.readPathID(r, "userID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ if _, err = client.FromContext(r.Context()).DeleteAdminUser(r.Context(), userID); err != nil {
+ var apiError *client.APIError
+ if errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict {
+ http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "owner"), http.StatusSeeOther)
+ return
+ }
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "done"), http.StatusSeeOther)
+}
+
+func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
+ return []client.EnvironmentVariable{
+ {Component: "Web", Name: "GARDOMATIC_ENV", Value: app.config.Env},
+ {Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
+ {Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
+ {Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
+ {Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
+ {Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
+ }
+}
+
+func (app *application) adminRoleCreatePost(w http.ResponseWriter, r *http.Request) {
+ var form roleSettingsForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ _, _, err := client.FromContext(r.Context()).CreateAdminRole(r.Context(), client.RoleInput{Name: strings.TrimSpace(form.Name), Scope: form.Scope, Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
+}
+
+func (app *application) adminRoleUpdatePost(w http.ResponseWriter, r *http.Request) {
+ var form roleSettingsForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ _, _, err := client.FromContext(r.Context()).UpdateAdminRole(r.Context(), form.Name, client.RoleInput{Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
+}
+
+func (app *application) adminRoleDeletePost(w http.ResponseWriter, r *http.Request) {
+ var form roleSettingsForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ if _, err := client.FromContext(r.Context()).DeleteAdminRole(r.Context(), form.Name); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
+}
+
+func adminRoleEditorAnchor(scope string) string {
+ if scope == "garden" {
+ return "#garden-role-templates"
+ }
+ return "#roles-settings"
+}
+
+func (app *application) adminApplicationSettingsPost(w http.ResponseWriter, r *http.Request) {
+ var form adminApplicationSettingsForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ timezone := strings.TrimSpace(form.Timezone)
+ input := client.ApplicationSettingsInput{
+ LifecycleStatusEnabled: &form.LifecycleStatusEnabled,
+ LifecycleRemovalMonth: &form.LifecycleRemovalMonth,
+ LifecycleRemovalDay: &form.LifecycleRemovalDay,
+ Timezone: &timezone,
+ }
+ if _, _, err := client.FromContext(r.Context()).UpdateAdminApplicationSettings(r.Context(), input); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#lifecycle-settings"), http.StatusSeeOther)
+}
+
+func (app *application) adminTestMailPost(w http.ResponseWriter, r *http.Request) {
+ var form adminTestMailForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ if _, err := client.FromContext(r.Context()).SendAdminTestMail(r.Context(), strings.TrimSpace(form.Email)); err != nil {
+ var apiError *client.APIError
+ if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
+ http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "invalid"), http.StatusSeeOther)
+ return
+ }
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "sent"), http.StatusSeeOther)
+}
+
+func (app *application) adminTaskPriorityCreatePost(w http.ResponseWriter, r *http.Request) {
+ var form adminTaskPriorityForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ name, active := strings.TrimSpace(form.Name), true
+ if _, _, err := client.FromContext(r.Context()).CreateAdminTaskPriority(r.Context(), client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &active}); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
+}
+
+func (app *application) adminTaskPriorityUpdatePost(w http.ResponseWriter, r *http.Request) {
+ id, err := app.readPathID(r, "priorityID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ var form adminTaskPriorityForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ name := strings.TrimSpace(form.Name)
+ if _, _, err := client.FromContext(r.Context()).UpdateAdminTaskPriority(r.Context(), id, client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &form.Active}); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
+}
+
+func (app *application) adminTaskPriorityDeletePost(w http.ResponseWriter, r *http.Request) {
+ id, err := app.readPathID(r, "priorityID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ if _, err := client.FromContext(r.Context()).DeleteAdminTaskPriority(r.Context(), id); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
+}
+
+func (app *application) adminSpeciesCategoryCreatePost(w http.ResponseWriter, r *http.Request) {
+ var form adminSpeciesCategoryForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ name, active := strings.TrimSpace(form.Name), true
+ input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &active, Lifecycle: &form.Lifecycle}
+ if _, _, err := client.FromContext(r.Context()).CreateAdminSpeciesCategory(r.Context(), input); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
+}
+
+func (app *application) adminSpeciesCategoryUpdatePost(w http.ResponseWriter, r *http.Request) {
+ categoryID, err := app.readPathID(r, "categoryID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ var form adminSpeciesCategoryForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ name := strings.TrimSpace(form.Name)
+ input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &form.Active, Lifecycle: &form.Lifecycle}
+ if _, _, err := client.FromContext(r.Context()).UpdateAdminSpeciesCategory(r.Context(), categoryID, input); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
+}
+
+func (app *application) adminSpeciesCategoryDeletePost(w http.ResponseWriter, r *http.Request) {
+ categoryID, err := app.readPathID(r, "categoryID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ if _, err := client.FromContext(r.Context()).DeleteAdminSpeciesCategory(r.Context(), categoryID); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
+}
+
+func (app *application) adminUserRolePost(w http.ResponseWriter, r *http.Request) {
+ userID, err := app.readPathID(r, "userID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ var form adminRoleForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ if _, _, err := client.FromContext(r.Context()).UpdateAdminUserRole(r.Context(), userID, form.Role); err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, adminPath(r, ""), http.StatusSeeOther)
+}
+
+func (app *application) settings(w http.ResponseWriter, r *http.Request) {
+ data := app.newTemplateData(r)
+ if !app.loadOptionalGarden(w, r, data) {
+ return
+ }
+ app.render(w, http.StatusOK, "settings.tmpl", data)
+}
diff --git a/internal/web/auth.go b/internal/web/auth.go
new file mode 100644
index 0000000..82f8393
--- /dev/null
+++ b/internal/web/auth.go
@@ -0,0 +1,191 @@
+package web
+
+import (
+ "encoding/base64"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+type signInForm struct {
+ CSRFToken string `form:"csrf_token"`
+ Email string `form:"email"`
+ Password string `form:"password"`
+ RememberEmail bool `form:"remember_email"`
+ Errors map[string]string
+ Message string
+}
+
+type activationForm struct {
+ CSRFToken string `form:"csrf_token"`
+ Token string `form:"token"`
+ Password string `form:"password"`
+ PasswordConfirm string `form:"password_confirm"`
+ SetPassword bool `form:"set_password"`
+ Errors map[string]string
+ Message string
+}
+
+func (app *application) signIn(w http.ResponseWriter, r *http.Request) {
+ if app.isAuthenticated(r) {
+ http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther)
+ return
+ }
+ data := app.newTemplateData(r)
+ form := signInForm{Errors: make(map[string]string)}
+ if cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil {
+ if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil {
+ form.Email, form.RememberEmail = string(decoded), true
+ }
+ }
+ data.Form = form
+ app.render(w, http.StatusOK, "signin.tmpl", data)
+}
+
+func (app *application) signInPost(w http.ResponseWriter, r *http.Request) {
+ var form signInForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ form.Email = strings.TrimSpace(form.Email)
+ form.Errors = make(map[string]string)
+ if form.Email == "" {
+ form.Errors["email"] = "E-Mail-Adresse ist erforderlich."
+ }
+ if form.Password == "" {
+ form.Errors["password"] = "Passwort ist erforderlich."
+ }
+ if len(form.Errors) != 0 {
+ data := app.newTemplateData(r)
+ data.Form = form
+ app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data)
+ return
+ }
+
+ apiClient := client.FromContext(r.Context())
+ user, response, err := apiClient.CreateSession(r.Context(), client.Credentials{Email: form.Email, Password: form.Password})
+ if err != nil {
+ var apiError *client.APIError
+ if errors.As(err, &apiError) && (apiError.StatusCode == http.StatusUnauthorized || apiError.StatusCode == http.StatusUnprocessableEntity) {
+ form.Message = "E-Mail-Adresse oder Passwort ist ungültig."
+ data := app.newTemplateData(r)
+ data.Form = form
+ app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data)
+ return
+ }
+ app.serverError(w, err)
+ return
+ }
+ client.ForwardCookies(w, response)
+ remembered := &http.Cookie{Name: "gardomatic_remembered_email", Path: webPath("login"), HttpOnly: true, Secure: app.config.CookieSecure, SameSite: http.SameSiteLaxMode}
+ if form.RememberEmail {
+ remembered.Value = base64.RawURLEncoding.EncodeToString([]byte(form.Email))
+ remembered.Expires = time.Now().Add(365 * 24 * time.Hour)
+ remembered.MaxAge = 365 * 24 * 60 * 60
+ } else if _, err := r.Cookie("gardomatic_remembered_email"); err == nil {
+ remembered.Expires = time.Unix(1, 0)
+ remembered.MaxAge = -1
+ } else {
+ remembered = nil
+ }
+ if remembered != nil {
+ http.SetCookie(w, remembered)
+ }
+ if !user.Activated {
+ http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther)
+}
+
+func (app *application) activateUser(w http.ResponseWriter, r *http.Request) {
+ if user, ok := userFromContext(r.Context()); ok && user.Activated {
+ http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
+ return
+ }
+
+ data := app.newTemplateData(r)
+ data.Form = activationForm{
+ Token: strings.TrimSpace(r.URL.Query().Get("token")),
+ SetPassword: r.URL.Query().Get("set-password") == "1",
+ Errors: make(map[string]string),
+ }
+ app.render(w, http.StatusOK, "activate.tmpl", data)
+}
+
+func (app *application) activateUserPost(w http.ResponseWriter, r *http.Request) {
+ var form activationForm
+ if err := app.decodePostForm(r, &form); err != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+
+ form.Token = strings.TrimSpace(form.Token)
+ form.Errors = make(map[string]string)
+ if form.Token == "" {
+ form.Errors["token"] = "Aktivierungstoken ist erforderlich."
+ }
+ if form.SetPassword {
+ if len(form.Password) < 8 {
+ form.Errors["password"] = "Das Passwort muss mindestens 8 Zeichen lang sein."
+ } else if len(form.Password) > 72 {
+ form.Errors["password"] = "Das Passwort darf höchstens 72 Zeichen lang sein."
+ }
+ if form.Password != form.PasswordConfirm {
+ form.Errors["password_confirm"] = "Die Passwörter stimmen nicht überein."
+ }
+ }
+
+ if len(form.Errors) == 0 {
+ apiClient := client.FromContext(r.Context())
+ var err error
+ if form.SetPassword {
+ _, _, err = apiClient.ActivateInvitedUser(r.Context(), form.Token, form.Password)
+ } else {
+ _, _, err = apiClient.ActivateUser(r.Context(), form.Token)
+ }
+ if err == nil {
+ http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther)
+ return
+ }
+
+ var apiError *client.APIError
+ if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
+ if _, ok := apiError.Validation["token"]; ok {
+ form.Errors["token"] = "Aktivierungstoken ist ungültig oder abgelaufen."
+ } else {
+ form.Errors = apiError.Validation
+ form.Message = "Der Account konnte nicht aktiviert werden."
+ }
+ } else {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ }
+
+ data := app.newTemplateData(r)
+ data.Form = form
+ app.render(w, http.StatusUnprocessableEntity, "activate.tmpl", data)
+}
+
+func (app *application) authenticatedLandingPage(r *http.Request) string {
+ if user, ok := userFromContext(r.Context()); ok && !user.Activated {
+ return webPath("activate")
+ }
+ return webPath("gardens")
+}
+
+func (app *application) signOutPost(w http.ResponseWriter, r *http.Request) {
+ apiClient := client.FromContext(r.Context())
+ response, err := apiClient.DeleteSession(r.Context())
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ client.ForwardCookies(w, response)
+ http.Redirect(w, r, webPath("home"), http.StatusSeeOther)
+}
diff --git a/internal/web/care_instructions.go b/internal/web/care_instructions.go
new file mode 100644
index 0000000..4e4cd64
--- /dev/null
+++ b/internal/web/care_instructions.go
@@ -0,0 +1,93 @@
+package web
+
+import (
+ "gardomatic.kleiax.de/lib/client"
+ "net/http"
+ "strings"
+)
+
+type careInstructionForm struct {
+ Text string `form:"text"`
+ Status string `form:"status"`
+}
+
+func (app *application) careInstructionSave(w http.ResponseWriter, r *http.Request) {
+ gardenID, e := app.readPathID(r, "gardenID")
+ if e != nil {
+ app.notFound(w)
+ return
+ }
+ speciesID, e := app.readPathID(r, "speciesID")
+ if e != nil {
+ app.notFound(w)
+ return
+ }
+ var form careInstructionForm
+ if e = app.decodePostForm(r, &form); e != nil {
+ app.clientError(w, http.StatusBadRequest)
+ return
+ }
+ form.Text = strings.TrimSpace(form.Text)
+ if form.Status == "" {
+ form.Status = "untested"
+ }
+ input := client.CareInstructionInput{Text: &form.Text, Status: &form.Status}
+ instructionID, _ := app.readPathID(r, "instructionID")
+ if instructionID > 0 {
+ _, _, e = client.FromContext(r.Context()).UpdateCareInstruction(r.Context(), gardenID, speciesID, instructionID, input)
+ } else {
+ _, _, e = client.FromContext(r.Context()).CreateCareInstruction(r.Context(), gardenID, speciesID, input)
+ }
+ if e != nil {
+ app.handleAPIError(w, r, e)
+ return
+ }
+ if r.Header.Get("HX-Request") == "true" {
+ apiClient := client.FromContext(r.Context())
+ instructions, _, loadErr := apiClient.CareInstructions(r.Context(), gardenID, speciesID)
+ if loadErr != nil {
+ app.handleAPIError(w, r, loadErr)
+ return
+ }
+ data := app.newTemplateData(r)
+ garden, _, loadErr := apiClient.Garden(r.Context(), gardenID)
+ if loadErr != nil {
+ app.handleAPIError(w, r, loadErr)
+ return
+ }
+ species, _, loadErr := apiClient.Species(r.Context(), gardenID, speciesID)
+ if loadErr != nil {
+ app.handleAPIError(w, r, loadErr)
+ return
+ }
+ data.Garden = &garden
+ data.SpeciesID = speciesID
+ data.SpeciesGlobal = species.GardenID == nil
+ data.CareInstructions = instructions
+ app.renderTemplate(w, http.StatusOK, "species_form.tmpl", "care_instruction_list", data)
+ return
+ }
+ http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther)
+}
+func (app *application) careInstructionDelete(w http.ResponseWriter, r *http.Request) {
+ gardenID, e := app.readPathID(r, "gardenID")
+ if e != nil {
+ app.notFound(w)
+ return
+ }
+ speciesID, e := app.readPathID(r, "speciesID")
+ if e != nil {
+ app.notFound(w)
+ return
+ }
+ instructionID, e := app.readPathID(r, "instructionID")
+ if e != nil {
+ app.notFound(w)
+ return
+ }
+ if _, e = client.FromContext(r.Context()).DeleteCareInstruction(r.Context(), gardenID, speciesID, instructionID); e != nil {
+ app.handleAPIError(w, r, e)
+ return
+ }
+ http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther)
+}
diff --git a/internal/web/collection_filters.go b/internal/web/collection_filters.go
new file mode 100644
index 0000000..21367a4
--- /dev/null
+++ b/internal/web/collection_filters.go
@@ -0,0 +1,219 @@
+package web
+
+import (
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+func collectionFilters(r *http.Request, keys ...string) map[string]string {
+ filters := make(map[string]string, len(keys))
+ for _, key := range keys {
+ filters[key] = strings.TrimSpace(r.URL.Query().Get(key))
+ }
+ return filters
+}
+
+func filterAndSortGardens(values []client.Garden, filters map[string]string) []client.Garden {
+ query := strings.ToLower(filters["q"])
+ result := make([]client.Garden, 0, len(values))
+ for _, value := range values {
+ if query != "" && !strings.Contains(strings.ToLower(value.Name+" "+value.Description), query) {
+ continue
+ }
+ if filters["role"] != "" && value.Role != filters["role"] {
+ continue
+ }
+ result = append(result, value)
+ }
+ sort.SliceStable(result, func(i, j int) bool {
+ switch filters["sort"] {
+ case "name_desc":
+ return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
+ case "newest":
+ return result[i].CreatedAt.After(result[j].CreatedAt)
+ case "oldest":
+ return result[i].CreatedAt.Before(result[j].CreatedAt)
+ default:
+ return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
+ }
+ })
+ return result
+}
+
+func filterAndSortPlants(values []client.Plant, assignments map[int][]client.PlantLocation, filters map[string]string) []client.Plant {
+ query := strings.ToLower(filters["q"])
+ speciesID, _ := strconv.Atoi(filters["species"])
+ locationID, _ := strconv.Atoi(filters["location"])
+ result := make([]client.Plant, 0, len(values))
+ for _, value := range values {
+ if query != "" && !containsTerms(query, value.Name, value.Notes, strings.Join(value.Tags, " ")) {
+ continue
+ }
+ if filters["status"] != "" && value.Status != filters["status"] {
+ continue
+ }
+ if speciesID > 0 && (value.SpeciesID == nil || *value.SpeciesID != speciesID) {
+ continue
+ }
+ if locationID > 0 && !plantHasLocation(assignments[value.ID], locationID) {
+ continue
+ }
+ result = append(result, value)
+ }
+ sort.SliceStable(result, func(i, j int) bool {
+ switch filters["sort"] {
+ case "name_desc":
+ return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
+ case "newest":
+ return result[i].CreatedAt.After(result[j].CreatedAt)
+ case "oldest":
+ return result[i].CreatedAt.Before(result[j].CreatedAt)
+ default:
+ return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
+ }
+ })
+ return result
+}
+
+func plantHasLocation(assignments []client.PlantLocation, locationID int) bool {
+ for _, assignment := range assignments {
+ if assignment.LocationID == locationID && assignment.RemovedAt == nil {
+ return true
+ }
+ }
+ return false
+}
+
+func filterAndSortSpecies(values []client.Species, filters map[string]string) []client.Species {
+ query := strings.ToLower(filters["q"])
+ result := make([]client.Species, 0, len(values))
+ for _, value := range values {
+ if query != "" && !containsTerms(query, value.CommonName, value.Cultivar, value.BotanicalName, value.Category, strings.Join(value.Tags, " ")) {
+ continue
+ }
+ if filters["origin"] == "global" && value.GardenID != nil || filters["origin"] == "garden" && value.GardenID == nil {
+ continue
+ }
+ result = append(result, value)
+ }
+ sort.SliceStable(result, func(i, j int) bool {
+ left, right := strings.ToLower(result[i].CommonName+" "+result[i].Cultivar), strings.ToLower(result[j].CommonName+" "+result[j].Cultivar)
+ switch filters["sort"] {
+ case "name_desc":
+ return left > right
+ case "newest":
+ return result[i].CreatedAt.After(result[j].CreatedAt)
+ case "oldest":
+ return result[i].CreatedAt.Before(result[j].CreatedAt)
+ default:
+ return left < right
+ }
+ })
+ return result
+}
+
+func filterAndSortLocations(values []client.Location, filters map[string]string) []client.Location {
+ query, kind := strings.ToLower(filters["q"]), strings.ToLower(filters["kind"])
+ result := make([]client.Location, 0, len(values))
+ for _, value := range values {
+ if query != "" && !containsTerms(query, value.Name, value.Description, value.Kind) {
+ continue
+ }
+ if kind != "" && !strings.Contains(strings.ToLower(value.Kind), kind) {
+ continue
+ }
+ result = append(result, value)
+ }
+ sort.SliceStable(result, func(i, j int) bool {
+ switch filters["sort"] {
+ case "name_desc":
+ return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name)
+ case "newest":
+ return result[i].CreatedAt.After(result[j].CreatedAt)
+ case "oldest":
+ return result[i].CreatedAt.Before(result[j].CreatedAt)
+ default:
+ return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
+ }
+ })
+ return result
+}
+
+func sortTasks(values []client.Task, sortBy string) {
+ sort.SliceStable(values, func(i, j int) bool {
+ switch sortBy {
+ case "due_desc":
+ return taskSortTime(values[i]).After(taskSortTime(values[j]))
+ case "month_asc":
+ return taskMonthBefore(values[i], values[j], false)
+ case "month_desc":
+ return taskMonthBefore(values[i], values[j], true)
+ case "period_asc":
+ return taskPeriodBefore(values[i], values[j], false)
+ case "period_desc":
+ return taskPeriodBefore(values[i], values[j], true)
+ case "name_asc":
+ return strings.ToLower(values[i].Title) < strings.ToLower(values[j].Title)
+ case "name_desc":
+ return strings.ToLower(values[i].Title) > strings.ToLower(values[j].Title)
+ case "priority_desc":
+ return values[i].Priority > values[j].Priority
+ default:
+ return taskSortTime(values[i]).Before(taskSortTime(values[j]))
+ }
+ })
+}
+
+// taskSortMonth returns the calendar month of the task's due window. Missing
+// dates sort after dated tasks in both directions.
+func taskSortMonth(task client.Task) int {
+ value := task.DueAtStart
+ if value == nil {
+ value = task.DueAtEnd
+ }
+ if value == nil {
+ return 13
+ }
+ return int(value.Month())
+}
+
+func taskMonthBefore(left, right client.Task, descending bool) bool {
+ leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil
+ rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil
+ if leftMissing || rightMissing {
+ return !leftMissing && rightMissing
+ }
+ if descending {
+ return taskSortMonth(left) > taskSortMonth(right)
+ }
+ return taskSortMonth(left) < taskSortMonth(right)
+}
+
+// taskSortPeriod returns the length of a task's due window. A single date is
+// a zero-length window; tasks without dates sort after dated tasks.
+func taskSortPeriod(task client.Task) time.Duration {
+ if task.DueAtStart == nil && task.DueAtEnd == nil {
+ return time.Duration(1<<63 - 1)
+ }
+ if task.DueAtStart == nil || task.DueAtEnd == nil {
+ return 0
+ }
+ return task.DueAtEnd.Sub(*task.DueAtStart)
+}
+
+func taskPeriodBefore(left, right client.Task, descending bool) bool {
+ leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil
+ rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil
+ if leftMissing || rightMissing {
+ return !leftMissing && rightMissing
+ }
+ if descending {
+ return taskSortPeriod(left) > taskSortPeriod(right)
+ }
+ return taskSortPeriod(left) < taskSortPeriod(right)
+}
diff --git a/internal/web/collection_filters_test.go b/internal/web/collection_filters_test.go
new file mode 100644
index 0000000..5236667
--- /dev/null
+++ b/internal/web/collection_filters_test.go
@@ -0,0 +1,94 @@
+package web
+
+import (
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+func TestCollectionFiltersAndSorting(t *testing.T) {
+ older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
+ newer := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+
+ gardens := filterAndSortGardens([]client.Garden{
+ {Name: "Ziergarten", Role: "viewer", CreatedAt: newer},
+ {Name: "Acker", Description: "Gemüse", Role: "owner", CreatedAt: older},
+ }, map[string]string{"q": "gemüse", "role": "owner", "sort": "newest"})
+ if len(gardens) != 1 || gardens[0].Name != "Acker" {
+ t.Fatalf("unexpected gardens result: %#v", gardens)
+ }
+
+ speciesID, locationID := 4, 8
+ plants := filterAndSortPlants([]client.Plant{
+ {ID: 1, Name: "Zucchini", Status: "active", SpeciesID: &speciesID, CreatedAt: newer},
+ {ID: 2, Name: "Aster", Status: "dormant", CreatedAt: older},
+ }, map[int][]client.PlantLocation{1: {{PlantID: 1, LocationID: locationID}}}, map[string]string{"status": "active", "species": "4", "location": "8", "sort": "name_desc"})
+ if len(plants) != 1 || plants[0].Name != "Zucchini" {
+ t.Fatalf("unexpected plants result: %#v", plants)
+ }
+
+ gardenID := 3
+ species := filterAndSortSpecies([]client.Species{
+ {CommonName: "Tomate", GardenID: &gardenID},
+ {CommonName: "Bohne", GardenID: nil},
+ }, map[string]string{"origin": "global", "sort": "name_asc"})
+ if len(species) != 1 || species[0].CommonName != "Bohne" {
+ t.Fatalf("unexpected species result: %#v", species)
+ }
+
+ locations := filterAndSortLocations([]client.Location{
+ {Name: "Nordbeet", Kind: "Beet"},
+ {Name: "Terrasse", Kind: "Topf"},
+ }, map[string]string{"kind": "beet", "sort": "name_asc"})
+ if len(locations) != 1 || locations[0].Name != "Nordbeet" {
+ t.Fatalf("unexpected locations result: %#v", locations)
+ }
+}
+
+func TestTaskSortingByMonthAndPeriod(t *testing.T) {
+ date := func(year int, month time.Month, day int) *time.Time {
+ value := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
+ return &value
+ }
+ tasks := []client.Task{
+ {ID: 1, Title: "Langer Zeitraum", DueAtStart: date(2026, time.March, 1), DueAtEnd: date(2026, time.March, 11)},
+ {ID: 2, Title: "Kurzer Zeitraum", DueAtStart: date(2026, time.January, 1), DueAtEnd: date(2026, time.January, 2)},
+ {ID: 3, Title: "Einzeltermin", DueAtStart: date(2026, time.February, 1)},
+ }
+
+ sortTasks(tasks, "month_asc")
+ if tasks[0].ID != 2 || tasks[1].ID != 3 || tasks[2].ID != 1 {
+ t.Fatalf("month sort = %#v, want January, February, March", tasks)
+ }
+ sortTasks(tasks, "period_desc")
+ if tasks[0].ID != 1 || tasks[1].ID != 2 || tasks[2].ID != 3 {
+ t.Fatalf("period sort = %#v, want longest first", tasks)
+ }
+}
+
+func TestTaskSearchDateFiltersUseStartOrEnd(t *testing.T) {
+ start := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, time.February, 2, 0, 0, 0, 0, time.UTC)
+ task := client.Task{DueAtStart: &start, DueAtEnd: &end}
+ if !taskInMonth(task, int(time.January)) || !taskInMonth(task, int(time.February)) {
+ t.Fatal("task should match both its start and end month")
+ }
+ if taskInMonth(task, int(time.March)) {
+ t.Fatal("task should not match a month between start and end")
+ }
+ if !taskInYear(task, 2026) || taskInYear(task, 2025) {
+ t.Fatal("task year filter matched the wrong year")
+ }
+ middleYear := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
+ if got := taskYears([]client.Task{task, {DueAtStart: &middleYear}}); len(got) != 3 || got[0] != 2024 || got[1] != 2025 || got[2] != 2026 {
+ t.Fatalf("task years = %#v, want [2024 2025 2026]", got)
+ }
+ filtered := filterTasks([]client.Task{
+ {ID: 1, DueAtStart: &start, DueAtEnd: &end},
+ {ID: 2, DueAtStart: func() *time.Time { value := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC); return &value }()},
+ }, map[string]string{"month": "2", "year": "2026"})
+ if len(filtered) != 1 || filtered[0].ID != 1 {
+ t.Fatalf("filtered tasks = %#v, want task 1", filtered)
+ }
+}
diff --git a/internal/web/context.go b/internal/web/context.go
new file mode 100644
index 0000000..71800fb
--- /dev/null
+++ b/internal/web/context.go
@@ -0,0 +1,19 @@
+package web
+
+import (
+ "context"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+type contextKey string
+
+const (
+ isAuthenticatedContextKey = contextKey("isAuthenticated")
+ userContextKey = contextKey("user")
+)
+
+func userFromContext(ctx context.Context) (client.User, bool) {
+ user, ok := ctx.Value(userContextKey).(client.User)
+ return user, ok
+}
diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go
new file mode 100644
index 0000000..7ef8d4a
--- /dev/null
+++ b/internal/web/dashboard.go
@@ -0,0 +1,475 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+func (app *application) gardenDashboard(w http.ResponseWriter, r *http.Request) {
+ gardenID, err := app.readPathID(r, "gardenID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ apiClient := client.FromContext(r.Context())
+ garden, _, err := apiClient.Garden(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ plants, _, err := apiClient.Plants(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ locations, _, err := apiClient.Locations(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ images, _, err := apiClient.Images(r.Context(), gardenID, "", "")
+ if err != nil {
+ var apiError *client.APIError
+ if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ }
+ horizon := time.Now().AddDate(0, 0, 30)
+ upcoming := make([]client.Task, 0)
+ for _, task := range tasks {
+ if task.CompletedAt != nil {
+ continue
+ }
+ due := task.DueAtEnd
+ if due == nil {
+ due = task.DueAtStart
+ }
+ if due != nil && !due.After(horizon) {
+ upcoming = append(upcoming, task)
+ }
+ }
+ sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) })
+ if len(upcoming) > 8 {
+ upcoming = upcoming[:8]
+ }
+ data := app.newTemplateData(r)
+ data.Garden = &garden
+ data.Tasks = upcoming
+ data.Plants = plants
+ data.Locations = locations
+ data.JournalEntries = journalEntries
+ data.PinboardEntries = pinboardEntries
+ data.PhotoCount = len(images)
+ app.render(w, http.StatusOK, "dashboard.tmpl", data)
+}
+
+func taskSortTime(task client.Task) time.Time {
+ if task.DueAtEnd != nil {
+ return *task.DueAtEnd
+ }
+ if task.DueAtStart != nil {
+ return *task.DueAtStart
+ }
+ return time.Unix(1<<62, 0)
+}
+
+func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) {
+ gardenID, err := app.readPathID(r, "gardenID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ view := r.URL.Query().Get("view")
+ if view != "month" {
+ view = "week"
+ }
+ anchor := time.Now()
+ if value := r.URL.Query().Get("date"); value != "" {
+ if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil {
+ anchor = parsed
+ }
+ }
+ start, end := calendarRange(anchor, view)
+ apiClient := client.FromContext(r.Context())
+ garden, _, err := apiClient.Garden(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ days := make([]calendarDay, 0)
+ for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
+ entry := calendarDay{Date: day}
+ for _, task := range tasks {
+ if task.CompletedAt == nil && taskOverlapsDay(task, day) {
+ entry.Tasks = append(entry.Tasks, task)
+ }
+ }
+ days = append(days, entry)
+ }
+ step := 7
+ if view == "month" {
+ step = 1
+ }
+ previous := start.AddDate(0, 0, -step)
+ next := end
+ if view == "month" {
+ previous = start.AddDate(0, -1, 0)
+ next = start.AddDate(0, 1, 0)
+ }
+ data := app.newTemplateData(r)
+ data.Garden = &garden
+ data.CalendarDays = days
+ data.CalendarStart = start
+ data.CalendarEnd = end.Add(-time.Nanosecond)
+ data.CalendarView = view
+ data.PreviousDate = previous.Format("2006-01-02")
+ data.NextDate = next.Format("2006-01-02")
+ app.render(w, http.StatusOK, "task_calendar.tmpl", data)
+}
+
+func calendarRange(anchor time.Time, view string) (time.Time, time.Time) {
+ y, m, d := anchor.Date()
+ loc := anchor.Location()
+ start := time.Date(y, m, d, 0, 0, 0, 0, loc)
+ if view == "month" {
+ start = time.Date(y, m, 1, 0, 0, 0, 0, loc)
+ return start, start.AddDate(0, 1, 0)
+ }
+ offset := (int(start.Weekday()) + 6) % 7
+ start = start.AddDate(0, 0, -offset)
+ return start, start.AddDate(0, 0, 7)
+}
+
+func taskOverlapsDay(task client.Task, day time.Time) bool {
+ dayEnd := day.AddDate(0, 0, 1)
+ start, end := task.DueAtStart, task.DueAtEnd
+ if start == nil {
+ start = end
+ }
+ if end == nil {
+ end = start
+ }
+ if start == nil {
+ return false
+ }
+ return start.Before(dayEnd) && !end.Before(day)
+}
+
+func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) {
+ gardenID, err := app.readPathID(r, "gardenID")
+ if err != nil {
+ app.notFound(w)
+ return
+ }
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ mode := r.URL.Query().Get("mode")
+ month, _ := strconv.Atoi(r.URL.Query().Get("month"))
+ if month < 1 || month > 12 {
+ month = 0
+ }
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year < 1 {
+ year = 0
+ }
+ apiClient := client.FromContext(r.Context())
+ garden, _, err := apiClient.Garden(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ tasks, _, err := apiClient.Tasks(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)}
+ if query != "" || month > 0 || year > 0 {
+ plants, _, err := apiClient.Plants(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ journal, _, err := apiClient.JournalEntries(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ tags, _, err := apiClient.GardenTags(r.Context(), gardenID)
+ if err != nil {
+ app.handleAPIError(w, r, err)
+ return
+ }
+ if mode != "month" {
+ if queryMonth := parseGermanMonth(query); queryMonth > 0 {
+ month = queryMonth
+ }
+ }
+ matchedTags := map[string]string{}
+ if mode != "month" {
+ for _, tag := range tags {
+ if containsTerms(query, tag) {
+ matchedTags[strings.ToLower(tag)] = tag
+ }
+ }
+ }
+ addMonthTags := func(values []string) {
+ if mode == "month" {
+ for _, tag := range values {
+ matchedTags[strings.ToLower(tag)] = tag
+ }
+ }
+ }
+ for _, item := range tasks {
+ textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " "))
+ monthMatch := month == 0 || taskInMonth(item, month)
+ yearMatch := year == 0 || taskInYear(item, year)
+ if textMatch && monthMatch && yearMatch {
+ addMonthTags(item.Tags)
+ result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item))
+ result.All = append(result.All, result.Tasks[len(result.Tasks)-1])
+ }
+ }
+ for _, item := range plants {
+ if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) {
+ addMonthTags(item.Tags)
+ result.Plants = append(result.Plants, plantSearchCard(gardenID, item))
+ result.All = append(result.All, result.Plants[len(result.Plants)-1])
+ }
+ }
+ for _, item := range species {
+ if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) {
+ addMonthTags(item.Tags)
+ result.Species = append(result.Species, speciesSearchCard(gardenID, item))
+ result.All = append(result.All, result.Species[len(result.Species)-1])
+ }
+ }
+ for _, item := range journal {
+ if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) {
+ addMonthTags(item.Tags)
+ result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item))
+ result.All = append(result.All, result.Journal[len(result.Journal)-1])
+ }
+ }
+ for _, item := range pinboard {
+ if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) {
+ addMonthTags(item.Tags)
+ result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item))
+ result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1])
+ }
+ }
+ seenTagUsages := map[string]bool{}
+ addTagUsages := func(card searchCard, values []string) {
+ for _, tag := range values {
+ if _, ok := matchedTags[strings.ToLower(tag)]; !ok {
+ continue
+ }
+ if seenTagUsages[card.URL] {
+ continue
+ }
+ seenTagUsages[card.URL] = true
+ usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)])
+ result.Tags = append(result.Tags, usage)
+ allSeen := false
+ for _, existing := range result.All {
+ if existing.URL == usage.URL {
+ allSeen = true
+ break
+ }
+ }
+ if !allSeen {
+ result.All = append(result.All, usage)
+ }
+ }
+ }
+ for _, item := range tasks {
+ addTagUsages(taskSearchCard(gardenID, item), item.Tags)
+ }
+ for _, item := range plants {
+ addTagUsages(plantSearchCard(gardenID, item), item.Tags)
+ }
+ for _, item := range species {
+ addTagUsages(speciesSearchCard(gardenID, item), item.Tags)
+ }
+ for _, item := range journal {
+ addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
+ }
+ for _, item := range pinboard {
+ addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
+ }
+ }
+ data := app.newTemplateData(r)
+ data.Garden = &garden
+ data.Search = result
+ app.render(w, http.StatusOK, "search.tmpl", data)
+}
+
+func taskSearchCard(gardenID int, item client.Task) searchCard {
+ return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)}
+}
+func plantSearchCard(gardenID int, item client.Plant) searchCard {
+ return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)}
+}
+func speciesSearchCard(gardenID int, item client.Species) searchCard {
+ title := item.CommonName
+ if item.Cultivar != "" {
+ title += " · " + item.Cultivar
+ }
+ return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)}
+}
+func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard {
+ path, label := "journal", "Tagebuch"
+ if item.EntryType == client.JournalEntryTypePinboard {
+ path, label = "pinboard", "Pinnwand"
+ }
+ return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)}
+}
+func tagUsageCard(card searchCard, tag string) searchCard {
+ card.Meta = "#" + tag + " · " + card.Meta
+ return card
+}
+func speciesSeason(item client.Species) string {
+ if item.HarvestMonthFrom != nil {
+ return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom))
+ }
+ return item.Category
+}
+func plantInMonth(item client.Plant, month int) bool {
+ return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month
+}
+func germanMonthName(month int) string {
+ names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
+ if month >= 1 && month <= 12 {
+ return names[month]
+ }
+ return ""
+}
+
+func containsTerms(query string, values ...string) bool {
+ query = strings.ToLower(strings.TrimSpace(query))
+ for _, term := range strings.Fields(query) {
+ term = strings.TrimPrefix(term, "#")
+ found := false
+ for _, value := range values {
+ if strings.Contains(strings.ToLower(value), term) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return query != ""
+}
+
+func parseKeywords(value string) []string {
+ 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
+}
+func parseGermanMonth(value string) int {
+ names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"}
+ value = strings.ToLower(strings.TrimSpace(value))
+ for i, name := range names {
+ if value == name || value == strings.TrimSuffix(name, "uar") {
+ return i + 1
+ }
+ }
+ return 0
+}
+func taskInMonth(task client.Task, month int) bool {
+ return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) ||
+ (task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month)
+}
+
+func taskInYear(task client.Task, year int) bool {
+ return (task.DueAtStart != nil && task.DueAtStart.Year() == year) ||
+ (task.DueAtEnd != nil && task.DueAtEnd.Year() == year)
+}
+
+func taskYears(tasks []client.Task) []int {
+ minYear, maxYear := 0, 0
+ setYear := func(year int) {
+ if minYear == 0 || year < minYear {
+ minYear = year
+ }
+ if year > maxYear {
+ maxYear = year
+ }
+ }
+ for _, task := range tasks {
+ if task.DueAtStart != nil {
+ setYear(task.DueAtStart.Year())
+ }
+ if task.DueAtEnd != nil {
+ setYear(task.DueAtEnd.Year())
+ }
+ }
+ if minYear == 0 {
+ return nil
+ }
+ result := make([]int, 0, maxYear-minYear+1)
+ for year := minYear; year <= maxYear; year++ {
+ result = append(result, year)
+ }
+ return result
+}
+func speciesInMonth(item client.Species, month int) bool {
+ return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo)
+}
+func monthInRange(month int, from, to *int) bool {
+ if from == nil || to == nil {
+ return false
+ }
+ return monthRangeMatches(month, *from, *to)
+}
+
+func monthRangeMatches(month, from, to int) bool {
+ if from <= to {
+ return month >= from && month <= to
+ }
+ return month >= from || month <= to
+}
diff --git a/internal/web/doc.go b/internal/web/doc.go
new file mode 100644
index 0000000..84879cd
--- /dev/null
+++ b/internal/web/doc.go
@@ -0,0 +1,3 @@
+// Package web implements Gardomatic's server-rendered browser application and
+// proxies authenticated user operations through the typed API client.
+package web
diff --git a/internal/web/edit_flows_test.go b/internal/web/edit_flows_test.go
new file mode 100644
index 0000000..4d943bf
--- /dev/null
+++ b/internal/web/edit_flows_test.go
@@ -0,0 +1,218 @@
+package web
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "gardomatic.kleiax.de/lib/client"
+ "github.com/julienschmidt/httprouter"
+)
+
+func TestPlantAndLocationCanBeEdited(t *testing.T) {
+ var plantInput client.PlantInput
+ var locationInput client.LocationInput
+ assignmentUpdated := false
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5":
+ body, _ := io.ReadAll(r.Body)
+ if err := json.Unmarshal(body, &plantInput); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Neue Rose"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9":
+ assignmentUpdated = true
+ _, _ = w.Write([]byte(`{"plant_location":{"id":9,"plant_id":5,"location_id":6,"quantity":2}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/locations/6":
+ body, _ := io.ReadAll(r.Body)
+ if err := json.Unmarshal(body, &locationInput); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = w.Write([]byte(`{"location":{"id":6,"garden_id":3,"name":"Südbeet"}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+
+ plantForm := url.Values{"name": {"Neue Rose"}, "species_id": {"0"}, "location_id": {"6"}, "assignment_id": {"9"}, "quantity": {"2"}, "status": {"active"}}
+ plantRequest := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(plantForm.Encode()))
+ plantRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ plantRequest = taskWebRequest(plantRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
+ plantResponse := httptest.NewRecorder()
+ app.plantEditPost(plantResponse, plantRequest)
+ if plantResponse.Code != http.StatusSeeOther || !plantInput.ClearSpeciesID || !assignmentUpdated {
+ t.Fatalf("plant edit: status=%d input=%+v assignment=%v body=%s", plantResponse.Code, plantInput, assignmentUpdated, plantResponse.Body.String())
+ }
+
+ locationForm := url.Values{"name": {"Südbeet"}, "parent_id": {"0"}, "area_sqm": {"4,5"}, "return_to": {"/g/3/locations"}}
+ locationRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/edit/6", strings.NewReader(locationForm.Encode()))
+ locationRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ locationRequest = taskWebRequest(locationRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "locationID", Value: "6"}})
+ locationResponse := httptest.NewRecorder()
+ app.locationEditPost(locationResponse, locationRequest)
+ if locationResponse.Code != http.StatusSeeOther || !locationInput.ClearParentID || locationInput.AreaSQM == nil || *locationInput.AreaSQM != 4.5 {
+ t.Fatalf("location edit: status=%d input=%+v body=%s", locationResponse.Code, locationInput, locationResponse.Body.String())
+ }
+}
+
+func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
+ var gardenInput client.UpdateGardenInput
+ var speciesInput client.SpeciesInput
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3":
+ body, _ := io.ReadAll(r.Body)
+ if err := json.Unmarshal(body, &gardenInput); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Neuer Garten"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/7":
+ body, _ := io.ReadAll(r.Body)
+ if err := json.Unmarshal(body, &speciesInput); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = w.Write([]byte(`{"species":{"id":7,"garden_id":3,"common_name":"Neue Rose"}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ gardenForm := url.Values{"name": {"Neuer Garten"}, "description": {"Südseite"}}
+ gardenRequest := httptest.NewRequest(http.MethodPost, "/gardens/edit/3", strings.NewReader(gardenForm.Encode()))
+ gardenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ gardenRequest = taskWebRequest(gardenRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
+ gardenResponse := httptest.NewRecorder()
+ app.gardenEditPost(gardenResponse, gardenRequest)
+ if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" {
+ t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput)
+ }
+ speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
+ speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode()))
+ speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}})
+ speciesResponse := httptest.NewRecorder()
+ app.speciesSave(speciesResponse, speciesRequest)
+ if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange {
+ t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput)
+ }
+}
+
+func TestGardenCanBeDeleted(t *testing.T) {
+ deleted := false
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || r.URL.Path != "/v1/gardens/3" {
+ http.NotFound(w, r)
+ return
+ }
+ deleted = true
+ w.WriteHeader(http.StatusNoContent)
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ request := httptest.NewRequest(http.MethodPost, "/gardens/delete/3", nil)
+ request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
+ response := httptest.NewRecorder()
+
+ app.gardenDeletePost(response, request)
+
+ if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/gardens" || !deleted {
+ t.Fatalf("garden delete: status=%d location=%q deleted=%v body=%s", response.Code, response.Header().Get("Location"), deleted, response.Body.String())
+ }
+}
+
+func TestTaskTemplateCanBeCreated(t *testing.T) {
+ var input client.SpeciesTaskTemplateInput
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method != http.MethodPost || r.URL.Path != "/v1/gardens/3/species/7/task-templates" {
+ http.NotFound(w, r)
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ if err := json.Unmarshal(body, &input); err != nil {
+ t.Fatal(err)
+ }
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Schneiden"}}`))
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ form := url.Values{"title": {"Schneiden"}, "trigger_type": {"month_of_year"}, "month_from": {"11"}, "day_from": {"15"}, "duration": {"2"}, "duration_unit": {"week"}, "recurrence": {"monthly"}, "recurrence_interval": {"2"}, "priority": {"5"}, "active": {"true"}}
+ request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
+ response := httptest.NewRecorder()
+ app.taskTemplateSave(response, request)
+ if response.Code != http.StatusSeeOther || input.TriggerType == nil || *input.TriggerType != "month_of_year" || input.MonthFrom == nil || *input.MonthFrom != 11 || input.DayFrom == nil || *input.DayFrom != 15 || input.Duration == nil || *input.Duration != 2 || input.RecurrenceInterval == nil || *input.RecurrenceInterval != 2 {
+ t.Fatalf("template create: status=%d input=%+v body=%s", response.Code, input, response.Body.String())
+ }
+ if input.TriggerOffset != nil || input.TriggerOffsetUnit != nil {
+ t.Errorf("calendar template unexpectedly sends disabled relative fields: %+v", input)
+ }
+}
+
+func TestTaskTemplateDialogShowsMissingNameError(t *testing.T) {
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3":
+ _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7":
+ _, _ = w.Write([]byte(`{"species":{"id":7,"common_name":"Tomate"}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/task-priorities":
+ _, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`))
+ default:
+ t.Errorf("unexpected API request: %s %s", r.Method, r.URL.Path)
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ form := url.Values{"trigger_type": {"month_of_year"}, "month_from": {"9"}, "day_from": {"1"}, "duration": {"0"}, "duration_unit": {"day"}, "trigger_offset_unit": {"day"}, "recurrence_interval": {"1"}}
+ request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.Header.Set("HX-Request", "true")
+ request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
+ response := httptest.NewRecorder()
+
+ app.taskTemplateSave(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
+ }
+ if body := response.Body.String(); !strings.Contains(body, "Ein Name ist erforderlich.") {
+ t.Errorf("missing name validation message: %s", body)
+ }
+ if response.Header().Get("HX-Retarget") != "#task-template-dialog-host" {
+ t.Errorf("HX-Retarget: got %q", response.Header().Get("HX-Retarget"))
+ }
+
+ form.Set("title", "Ausgegeizte Triebe entfernen")
+ form.Set("month_from", "0")
+ request = httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.Header.Set("HX-Request", "true")
+ request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
+ response = httptest.NewRecorder()
+
+ app.taskTemplateSave(response, request)
+
+ body := response.Body.String()
+ if response.Code != http.StatusOK {
+ t.Fatalf("status with filled name: got %d, want %d; body: %s", response.Code, http.StatusOK, body)
+ }
+ if !strings.Contains(body, "value='Ausgegeizte Triebe entfernen'") {
+ t.Errorf("filled name was not preserved: %s", body)
+ }
+ if strings.Contains(body, "Ein Name ist erforderlich.") {
+ t.Errorf("filled name was incorrectly rejected: %s", body)
+ }
+ if !strings.Contains(body, "Tag und Monat für „Ab“ sind erforderlich.") {
+ t.Errorf("actual date validation message is missing: %s", body)
+ }
+}
diff --git a/internal/web/efs.go b/internal/web/efs.go
new file mode 100644
index 0000000..9006454
--- /dev/null
+++ b/internal/web/efs.go
@@ -0,0 +1,8 @@
+package web
+
+import (
+ "embed"
+)
+
+//go:embed "templates" "static"
+var files embed.FS
diff --git a/internal/web/flows_test.go b/internal/web/flows_test.go
new file mode 100644
index 0000000..c2b1532
--- /dev/null
+++ b/internal/web/flows_test.go
@@ -0,0 +1,626 @@
+package web
+
+import (
+ "html"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "gardomatic.kleiax.de/lib/client"
+ "github.com/go-playground/form/v4"
+)
+
+type webHandlerTransport struct {
+ handler http.Handler
+}
+
+func (transport webHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
+ recorder := httptest.NewRecorder()
+ transport.handler.ServeHTTP(recorder, request)
+ response := recorder.Result()
+ response.Request = request
+ return response, nil
+}
+
+func newAPIBackedTestApplication(t *testing.T, handler http.Handler) *application {
+ t.Helper()
+ templateCache, err := newTemplateCache()
+ if err != nil {
+ t.Fatal(err)
+ }
+ apiClient, err := client.New("https://api.example", client.WithHTTPClient(&http.Client{Transport: webHandlerTransport{handler: handler}}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &application{
+ config: Config{SessionCookieName: "gardomatic_session", CookieSecure: false},
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ templateCache: templateCache,
+ formDecoder: form.NewDecoder(),
+ apiClient: apiClient,
+ }
+}
+
+func TestSettingsKeepsSelectedGarden(t *testing.T) {
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/session":
+ _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
+ case "/v1/gardens/3":
+ _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ request := httptest.NewRequest(http.MethodGet, "/settings?garden=3", nil)
+ response := httptest.NewRecorder()
+
+ app.routes().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
+ }
+ body := response.Body.String()
+ if !strings.Contains(body, "href='/g/3'>Hinterhof") || !strings.Contains(body, "href='/settings?garden=3'") {
+ t.Fatalf("selected garden was not kept in settings: %s", body)
+ }
+}
+
+func TestHealthAndPrivacyPagesArePublic(t *testing.T) {
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/session":
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
+ case "/v1/healthcheck":
+ _, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+
+ for _, test := range []struct {
+ path string
+ want string
+ }{
+ {"/healtcheck", "Serverzeit"},
+ {"/datenschutz", "Verarbeitete Daten"},
+ } {
+ request := httptest.NewRequest(http.MethodGet, test.path, nil)
+ response := httptest.NewRecorder()
+ app.routes().ServeHTTP(response, request)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), test.want) {
+ t.Errorf("GET %s: status=%d, missing %q: %s", test.path, response.Code, test.want, response.Body.String())
+ }
+ }
+}
+
+func TestPinboardRequestsOnlyPinboardEntries(t *testing.T) {
+ requestedType := ""
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/session":
+ _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
+ case "/v1/gardens/3":
+ _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
+ case "/v1/gardens/3/journal":
+ requestedType = r.URL.Query().Get("type")
+ _, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"garden_id":3,"entry_type":"pinboard","title":"Sitzecke","body":"Bank bauen"}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ request := httptest.NewRequest(http.MethodGet, "/g/3/pinboard", nil)
+ response := httptest.NewRecorder()
+
+ app.routes().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
+ }
+ if requestedType != client.JournalEntryTypePinboard {
+ t.Fatalf("entry type: got %q, want %q", requestedType, client.JournalEntryTypePinboard)
+ }
+ if body := response.Body.String(); !strings.Contains(body, "Sitzecke") || !strings.Contains(body, "/g/3/pinboard/edit/8") {
+ t.Fatalf("pinboard entry missing: %s", body)
+ }
+}
+
+func TestGardenPageUsesIncomingSession(t *testing.T) {
+ apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/session":
+ cookie, err := r.Cookie("gardomatic_session")
+ if err != nil || cookie.Value != "browser-session" {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "browser-session", Path: "/", HttpOnly: true, MaxAge: 1800})
+ _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
+ case "/v1/gardens":
+ _, _ = w.Write([]byte(`{"gardens":[{"id":3,"name":"Hinterhof","description":"Gemüse"}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ app := newAPIBackedTestApplication(t, apiHandler)
+ request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
+ request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
+ response := httptest.NewRecorder()
+
+ app.routes().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
+ }
+ var refreshedSession *http.Cookie
+ for _, cookie := range response.Result().Cookies() {
+ if cookie.Name == "gardomatic_session" {
+ refreshedSession = cookie
+ break
+ }
+ }
+ if refreshedSession == nil || refreshedSession.Value != "browser-session" || refreshedSession.MaxAge != 1800 {
+ t.Errorf("refreshed session cookie was not forwarded: got %+v", refreshedSession)
+ }
+ if body := response.Body.String(); !strings.Contains(body, "Hinterhof") || !strings.Contains(body, "Alice") {
+ t.Errorf("page does not contain garden and user: %s", body)
+ }
+ body := response.Body.String()
+ if !strings.Contains(body, `href='/gardens/new'`) {
+ t.Errorf("page does not link to the garden creation page: %s", body)
+ }
+ for _, want := range []string{"class='user-menu'", `href='/gardens'`, `href='/account'`, ">Nutzer ", "Abmelden"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("user menu does not contain %q: %s", want, body)
+ }
+ }
+ if strings.Contains(body, ``) {
+ t.Errorf("garden overview unexpectedly renders an empty garden navigation: %s", body)
+ }
+ if strings.Contains(body, `
";function wa(e,t,n){var r=parseInt(e.left,10),o=parseInt(e.top,10),i=parseInt(e.width,10)+parseInt(e.paddingLeft,10)+parseInt(e.paddingRight,10),s=parseInt(e.height,10)+parseInt(e.paddingTop,10)+parseInt(e.paddingBottom,10);return t>=r&&t<=r+i&&n>=o&&n<=o+s}var ka="toastui-editor-";function xa(){for(var e=[],t=0;t/g,"")).replace(/ class="ProseMirror-trailingBreak"/g,"")}var Ia=new un("widget"),Ra=function(){function e(e,t){var n=this;this.popup=null,this.removeWidget=function(){n.popup&&(n.rootEl.removeChild(n.popup),n.popup=null)},this.rootEl=e.dom.parentElement,this.eventEmitter=t,this.eventEmitter.listen("blur",this.removeWidget),this.eventEmitter.listen("loadUI",(function(){n.rootEl=Oa(e.dom.parentElement,"."+xa("defaultUI"))})),this.eventEmitter.listen("removePopupWidget",this.removeWidget)}return e.prototype.update=function(e){var t=Ia.getState(e.state);if(this.removeWidget(),t){var n=t.node,r=t.style,o=e.coordsAtPos(t.pos),i=o.top,s=o.left,a=o.bottom-i,l=this.rootEl.getBoundingClientRect(),c=i-l.top;be()(n,{opacity:"0"}),this.rootEl.appendChild(n),be()(n,{position:"absolute",left:s-l.left+5+"px",top:("bottom"===r?c+a-5:c-a)+"px",opacity:"1"}),this.popup=n,e.focus()}},e.prototype.destroy=function(){this.eventEmitter.removeEventHandler("blur",this.removeWidget)},e}();function Pa(e){return new an({key:Ia,state:{init:function(){return null},apply:function(e){return e.getMeta("widget")}},view:function(t){return new Ra(t,e)}})}var Ba=n(893),Fa=n.n(Ba);function Ha(e,t,n){e.emit("addImageBlobHook",t,(function(n,r){e.emit("command","addImage",{imageUrl:n,altText:r||t.name||"image"})}),n)}function za(e){var t=vi()(e).filter((function(e){return-1!==e.type.indexOf("image")}));if(1===t.length){var n=t[0];if(n)return n.getAsFile()}return null}function qa(e){var t=e.eventEmitter;return new an({props:{handleDOMEvents:{drop:function(e,n){var r,o=null===(r=n.dataTransfer)||void 0===r?void 0:r.files;return o&&Fa()(o,(function(e){return-1===e.type.indexOf("image")||(n.preventDefault(),n.stopPropagation(),Ha(t,e,n.type),!1)})),!0}}}})}var Va=function(){function e(){}return Object.defineProperty(e.prototype,"type",{get:function(){return"node"},enumerable:!1,configurable:!0}),e.prototype.setContext=function(e){this.context=e},e}();function ja(e){var t=document.createElement("span"),n=Ys(e.attrs.info,e.textContent);return t.className="tui-widget",t.appendChild(n),{dom:t}}function $a(e){return"widget"===e.type.name}var _a=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"widget"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{attrs:{info:{default:null}},group:"inline",inline:!0,content:"text*",selectable:!1,atom:!0,toDOM:function(){return["span",{class:"tui-widget"},0]},parseDOM:[{tag:"span.tui-widget",getAttrs:function(e){return{info:e.textContent.match(/\$\$(widget\d+)/)[1]}}}]}},enumerable:!1,configurable:!0}),n}(Va),Ua=function(){function e(e){this.timer=null,this.el=document.createElement("div"),this.el.className="toastui-editor",this.eventEmitter=e,this.placeholder={text:""}}return e.prototype.createState=function(){return on.create({schema:this.schema,plugins:this.createPlugins()})},e.prototype.initEvent=function(){var e=this,t=e.eventEmitter,n=e.view,r=e.editorType;n.dom.addEventListener("focus",(function(){return t.emit("focus",r)})),n.dom.addEventListener("blur",(function(){return t.emit("blur",r)}))},e.prototype.emitChangeEvent=function(e){this.eventEmitter.emit("caretChange",this.editorType),e.docChanged&&this.eventEmitter.emit("change",this.editorType)},Object.defineProperty(e.prototype,"defaultPlugins",{get:function(){var e,t=this.createInputRules(),n=i(i([],this.keymaps),[Ai(o({"Shift-Enter":ts.Enter},ts)),Ts(),(e=this.placeholder,new an({props:{decorations:function(t){var n=t.doc;if(e.text&&1===n.childCount&&n.firstChild.isTextblock&&0===n.firstChild.content.size){var r=document.createElement("span");return ke()(r,"placeholder"),e.className&&ke()(r,e.className),r.textContent=e.text,_o.create(n,[Vo.widget(1,r)])}return null}}})),Pa(this.eventEmitter),qa(this.context)]);return t?n.concat(t):n},enumerable:!1,configurable:!0}),e.prototype.createInputRules=function(){var e=ea().map((function(e){var t=e.rule;return new ns(t,(function(e,n,r,o){var i=e.schema,s=e.tr,a=e.doc,l=n.input.match(new RegExp(t,"g")),c=a.resolve(r),u=c.parent,d=0;if($a(u)&&(u=c.node(c.depth-1)),u.forEach((function(e){return $a(e)&&(d+=1)})),l.length>d){var p=et(l),h=ra(p,i);return s.replaceWith(o-p.length+1,o,h)}return null}))}));return e.length?rs({rules:e}):null},e.prototype.clearTimer=function(){this.timer&&(clearTimeout(this.timer),this.timer=null)},e.prototype.createSchema=function(){return new Y({nodes:this.specs.nodes,marks:this.specs.marks})},e.prototype.createKeymaps=function(e){var t=ia(),n=t.undo,r=t.redo,o=this.specs.keymaps(e),i={"Mod-z":n(),"Shift-Mod-z":r()};return e?o.concat(Ai(i)):o},e.prototype.createCommands=function(){return this.specs.commands(this.view)},e.prototype.createPluginProps=function(){var e=this;return this.extraPlugins.map((function(t){return t(e.eventEmitter)}))},e.prototype.focus=function(){var e=this;this.clearTimer(),this.timer=setTimeout((function(){e.view.focus(),e.view.dispatch(e.view.state.tr.scrollIntoView())}))},e.prototype.blur=function(){this.view.dom.blur()},e.prototype.destroy=function(){var e=this;this.clearTimer(),this.view.destroy(),Object.keys(this).forEach((function(t){delete e[t]}))},e.prototype.moveCursorToStart=function(e){var t=this.view.state.tr;this.view.dispatch(t.setSelection(Os(t,1)).scrollIntoView()),e&&this.focus()},e.prototype.moveCursorToEnd=function(e){var t=this.view.state.tr;this.view.dispatch(t.setSelection(Os(t,t.doc.content.size-1)).scrollIntoView()),e&&this.focus()},e.prototype.setScrollTop=function(e){this.view.dom.scrollTop=e},e.prototype.getScrollTop=function(){return this.view.dom.scrollTop},e.prototype.setPlaceholder=function(e){this.placeholder.text=e,this.view.dispatch(this.view.state.tr.scrollIntoView())},e.prototype.setHeight=function(e){be()(this.el,{height:e+"px"})},e.prototype.setMinHeight=function(e){be()(this.el,{minHeight:e+"px"})},e.prototype.getElement=function(){return this.el},e}(),Wa=Ua,Ja=n(294),Ga=n.n(Ja),Ka=["Enter","Shift-Enter","Mod-Enter","Tab","Shift-Tab","Delete","Backspace","Mod-Delete","Mod-Backspace","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Mod-d","Mod-D","Alt-ArrowUp","Alt-ArrowDown"];function Za(e,t,n){return e.focus(),t(n)(e.state,e.dispatch,e)}var Xa=function(){function e(e){this.specs=e}return Object.defineProperty(e.prototype,"nodes",{get:function(){return this.specs.filter((function(e){return"node"===e.type})).reduce((function(e,t){var n,r=t.name,i=t.schema;return o(o({},e),((n={})[r]=i,n))}),{})},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"marks",{get:function(){return this.specs.filter((function(e){return"mark"===e.type})).reduce((function(e,t){var n,r=t.name,i=t.schema;return o(o({},e),((n={})[r]=i,n))}),{})},enumerable:!1,configurable:!0}),e.prototype.commands=function(e,t){var n=this.specs.filter((function(e){return e.commands})).reduce((function(t,n){var r={},i=n.commands();return Ga()(i)?r[n.name]=function(t){return Za(e,i,t)}:Object.keys(i).forEach((function(t){r[t]=function(n){return Za(e,i[t],n)}})),o(o({},t),r)}),{}),r=ia();return Object.keys(r).forEach((function(t){n[t]=function(n){return Za(e,r[t],n)}})),t&&Object.keys(t).forEach((function(r){n[r]=function(n){return Za(e,t[r],n)}})),n},e.prototype.keymaps=function(e){return this.specs.filter((function(e){return e.keymaps})).map((function(e){return e.keymaps()})).map((function(t){return e||Object.keys(t).forEach((function(e){Ue(Ka,e)||delete t[e]})),Ai(t)}))},e.prototype.setContext=function(e){this.specs.forEach((function(t){t.setContext(e)}))},e}(),Qa=Xa;function Ya(e){var t=e.from,n=e.to;return e instanceof Kt?[t+1,n-1]:[t,n]}function el(e){return e.index(0)+1}function tl(e,t,n){void 0===n&&(n=1);var r=0;return e.forEach((function(e,o){$a(e)&&o+2 ?/,Pl=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"blockQuote"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{toDOM:function(){return["span",{class:Ca("block-quote")},0]}}},enumerable:!1,configurable:!0}),n.prototype.createBlockQuoteText=function(e,t){return t?e.replace(Rl,"").trim():"> "+e.trim()},n.prototype.extendBlockQuote=function(){var e=this;return function(t,n){var r=t.selection,o=t.doc,i=t.tr,s=t.schema,a=il(r),l=a.endFromOffset,c=a.endToOffset,u=a.endIndex,d=a.to,p=Il(o,u);if(Rl.test(p)&&d>l&&r.empty){if(!p.replace(Rl,"").trim())i.deleteRange(l,c).split(i.mapping.map(c));else{var h=p.slice(d-l).trim();Ls(i,c,h,Ns(s,e.createBlockQuoteText(h)))}return n(i),!0}return!1}},n.prototype.commands=function(){var e=this;return function(){return function(t,n){var r=t.selection,o=t.doc,i=il(r),s=i.startFromOffset,a=i.endToOffset,l=i.startIndex,c=i.endIndex,u=Rl.test(Il(o,l)),d=As({state:t,startIndex:l,endIndex:c,from:s,createText:function(t){return e.createBlockQuoteText(t,u)}});return n(d.setSelection(Os(d,d.mapping.map(a)))),!0}}},n.prototype.keymaps=function(){var e=this.commands()();return{"alt-q":e,"alt-Q":e,Enter:this.extendBlockQuote()}},n}(Al),Bl=/(^\s*)([-*+] |[\d]+\. )/,Fl=/(^\s*)([\d])+\.( \[[ xX]])? /,Hl=/^(\s*)((\d+)([.)]\s(?:\[(?:x|\s)\]\s)?))(.*)/,zl=/(^\s*)([-*+]|[\d]+\.)( \[[ xX]])? /,ql=/^(\s*)([-*+]+(\s(?:\[(?:x|\s)\]\s)?))(.*)/,Vl=/(^\s*)([-*+] |[\d]+\. )(\[[ xX]] )/,jl=/(^\s*)([-*+])( \[[ xX]]) /;function $l(e){return Fl.test(e)?"ordered":"bullet"}function _l(e){for(var t=0;e&&"document"!==e.type;)"list"===e.type&&(t+=1),e=e.parent;return t}function Ul(e,t,n,r){for(var o=e.getLineTexts().length,i=[],s=t;r?s1;){s=r?s+1:s-1;var a=e.findFirstNodeAtLine(s),l=_l(a);if(l===n)i.push({line:s,depth:n,mdNode:a});else if(l0;c-=1){var u=t.findFirstNodeAtLine(c),d=Ll(n,c)&&!!js(u,(function(e){return Hs(e)})),p=Hl.exec(Ll(n,c));if(!p&&!d)break;if(p||!d){var h=p,f=h[1],m=h[3];if(!f){s=Number(m),a=c;break}}else l+=1}return{changedResults:[{text:s+r-a-l+". "+i,line:r}]}},task:function(e){var t=e.doc,n=e.line;return{changedResults:[{text:"* [ ] "+Ll(t,n),line:n}]}}},Zl={bullet:function(e){var t=e.line,n=Ll(e.doc,t),r=ql.exec(n);return{listSyntax:""+r[1]+r[2]}},ordered:function(e){var t=e.toastMark,n=e.line,r=e.mdNode,i=e.doc,s=_l(r),a=Ll(i,n),l=Hl.exec(a),c=l[1],u=l[3],d=l[4],p=Number(u)+1,h=""+c+p+d,f=Ul(t,n,s,!0).filter((function(e){var t=Hl.exec(Ll(i,e.line));return t&&t[1].length===c.length&&!!js(e.mdNode,(function(e){return zs(e)}))}));return o({listSyntax:h},Wl(i,f,"ordered",p))}};function Xl(e,t,n,r,o){for(var i=[],s=Ll(e,n),a=Hl.exec(s);a;){var l=a[1],c=a[4],u=a[5],d=l.length;if(d===o)i.push(Ns(t,""+l+r+c+u)),r+=1,n+=1;else if(d>o){var p=Xl(e,t,n,1,d);n=p.line,i=i.concat(p.nodes)}if(de.childCount)break;s=Ll(e,n),a=Hl.exec(s)}return{nodes:i,line:n}}var Ql=/(^\s{1,4})(.*)/;function Yl(e,t,n){return e0){var u=r.child(c-1),d=u.nodeSize,p=u.textContent;return n.delete(a-d,a).split(n.mapping.map(l)).insert(n.mapping.map(l),Ns(i,p)),t(n),!0}return!1}},n.prototype.commands=function(){return{indent:this.indent(),outdent:this.outdent()}},n.prototype.keymaps=function(){return{Tab:this.indent(!0)(),"Shift-Tab":this.outdent(!0)(),"Mod-d":this.deleteLines(),"Mod-D":this.deleteLines(),"Alt-ArrowUp":this.moveUp(),"Alt-ArrowDown":this.moveDown()}},n}(Va),rc=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"text"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{group:"inline"}},enumerable:!1,configurable:!0}),n}(Va),oc=/^#{1,6}\s/,ic=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"heading"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{attrs:{level:{default:1},seText:{default:!1}},toDOM:function(e){var t=e.attrs,n="heading|heading"+t.level;return t.seText&&(n+="|delimiter|setext"),["span",{class:Ca.apply(void 0,n.split("|"))},0]}}},enumerable:!1,configurable:!0}),n.prototype.createHeadingText=function(e,t,n){for(var r=t.replace(n,"").trim(),o="";e>0;)o+="#",e-=1;return o+" "+r},n.prototype.commands=function(){var e=this;return function(t){return function(n,r){var o=t.level,i=il(n.selection),s=i.startFromOffset,a=i.endToOffset,l=As({state:n,from:s,startIndex:i.startIndex,endIndex:i.endIndex,createText:function(t){var n=t.match(oc),r=n?n[0]:"";return e.createHeadingText(o,t,r)}});return r(l.setSelection(Os(l,l.mapping.map(a)))),!0}}},n}(Al),sc="```",ac=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"codeBlock"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{toDOM:function(){return["span",{class:Ca("code-block")},0]}}},enumerable:!1,configurable:!0}),n.prototype.commands=function(){return function(){return function(e,t){var n=e.selection,r=e.schema,o=e.tr,i=il(n),s=i.startFromOffset,a=i.endToOffset,l=Ns(r,sc);return o.insert(s,l).split(s+sc.length),o.split(o.mapping.map(a)).insert(o.mapping.map(a),l),t(o.setSelection(Os(o,o.mapping.map(a)-(sc.length+2)))),!0}}},n.prototype.keepIndentation=function(){var e=this;return function(t,n){var r=t.selection,o=t.tr,i=t.doc,s=t.schema,a=e.context.toastMark,l=il(r),c=l.startFromOffset,u=l.endToOffset,d=l.endIndex,p=l.from,h=l.to,f=Il(i,d);if(p===h&&f.trim()){var m=f.match(/^\s+/);if(function(e){return e&&"codeBlock"===e.type}(a.findFirstNodeAtLine(d+1))&&m){var g=m[0],v=f.slice(h-c);return Ls(o,u,v,Ns(s,g+v)),n(o),!0}}return!1}},n.prototype.keymaps=function(){var e=this.commands()();return{"Shift-Mod-p":e,"Shift-Mod-P":e,Enter:this.keepIndentation()}},n}(Al),lc=/\||\s/g;function cc(e,t){for(var n="|",r=0;r0&&(t+="\n")})),t},n.prototype.setSelection=function(e,t){void 0===t&&(t=e);var n=this.view.state.tr,r=ol(n.doc,e,t),o=r[0],i=r[1];this.view.dispatch(n.setSelection(Os(n,o,i)).scrollIntoView())},n.prototype.replaceSelection=function(e,t,n){var r,o=this.view.state,i=o.tr,s=o.schema,a=o.doc,l=e.split(Pc).map((function(e){return Es(s,ra(e,s))})),c=new v(d.from(l),1,1);if(this.focus(),t&&n){var u=ol(a,t,n),p=u[0],h=u[1];r=i.replaceRange(p,h,c)}else r=i.replaceSelection(c);this.view.dispatch(r.scrollIntoView())},n.prototype.deleteSelection=function(e,t){var n,r=this.view.state,o=r.tr,i=r.doc;if(e&&t){var s=ol(i,e,t),a=s[0],l=s[1];n=o.deleteRange(a,l)}else n=o.deleteSelection();this.view.dispatch(n.scrollIntoView())},n.prototype.getSelectedText=function(e,t){var n=this.view.state,r=n.doc,o=n.selection,i=o.from,s=o.to;if(e&&t){var a=ol(r,e,t);i=a[0],s=a[1]}return r.textBetween(i,s,"\n")},n.prototype.getSelection=function(){var e=this.view.state.selection,t=e.from,n=e.to;return nl(this.view.state.tr.doc,t,n)},n.prototype.setMarkdown=function(e,t){void 0===t&&(t=!0);var n=e.split(Pc),r=this.view.state,o=r.tr,i=r.doc,s=r.schema,a=n.map((function(e){return Es(s,ra(e,s))}));this.view.dispatch(o.replaceWith(0,i.content.size,a)),t&&this.moveCursorToEnd(!0)},n.prototype.addWidget=function(e,t,n){var r=this.view.state,o=r.tr,i=r.doc,s=r.selection,a=n?ol(i,n,n)[0]:s.to;this.view.dispatch(o.setMeta("widget",{pos:a,node:e,style:t}))},n.prototype.replaceWithWidget=function(e,t,n){var r=this.view.state,o=r.tr,i=r.schema,s=ol(r.doc,e,t),a=ra(n,i);this.view.dispatch(o.replaceWith(s[0],s[1],a))},n.prototype.getRangeInfoOfNode=function(e){var t=this.view.state,n=t.doc,r=t.selection,o=e||nl(n,r.from)[0],i=this.toastMark.findNodeAtPosition(o);return"text"===i.type&&"paragraph"!==i.parent.type&&(i=i.parent),i.sourcepos[1][1]+=1,{range:i.sourcepos,type:i.type}},n.prototype.getMarkdown=function(){return this.toastMark.getLineTexts().map((function(e){return Xs(e)})).join("\n")},n.prototype.getToastMark=function(){return this.toastMark},n}(Wa),Fc=Bc,Hc=n(349),zc=n.n(Hc),qc=n(348),Vc=n.n(qc),jc=function(e,t){return jc=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])},jc(e,t)};function $c(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function n(){this.constructor=e}jc(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}var _c=function(){return _c=Object.assign||function(e){for(var t,n=1,r=arguments.length;n=55296&&i<=57343){if(i>=55296&&i<=56319&&r+1=56320&&s<=57343){l+=encodeURIComponent(e[r]+e[r+1]),r++;continue}l+="%EF%BF%BD"}else l+=encodeURIComponent(e[r]);return l}Gc.defaultChars=";/?:@&=+$,-_.!~*'()#",Gc.componentChars="-_.!~*'()";var Kc=Gc,Zc={},Xc={},Qc={Aacute:"Á",aacute:"á",Abreve:"Ă",abreve:"ă",ac:"∾",acd:"∿",acE:"∾̳",Acirc:"Â",acirc:"â",acute:"´",Acy:"А",acy:"а",AElig:"Æ",aelig:"æ",af:"",Afr:"𝔄",afr:"𝔞",Agrave:"À",agrave:"à",alefsym:"ℵ",aleph:"ℵ",Alpha:"Α",alpha:"α",Amacr:"Ā",amacr:"ā",amalg:"⨿",amp:"&",AMP:"&",andand:"⩕",And:"⩓",and:"∧",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angmsd:"∡",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",Aogon:"Ą",aogon:"ą",Aopf:"𝔸",aopf:"𝕒",apacir:"⩯",ap:"≈",apE:"⩰",ape:"≊",apid:"≋",apos:"'",ApplyFunction:"",approx:"≈",approxeq:"≊",Aring:"Å",aring:"å",Ascr:"𝒜",ascr:"𝒶",Assign:"≔",ast:"*",asymp:"≈",asympeq:"≍",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",awconint:"∳",awint:"⨑",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",Backslash:"∖",Barv:"⫧",barvee:"⊽",barwed:"⌅",Barwed:"⌆",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",Bcy:"Б",bcy:"б",bdquo:"„",becaus:"∵",because:"∵",Because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",Bernoullis:"ℬ",Beta:"Β",beta:"β",beth:"ℶ",between:"≬",Bfr:"𝔅",bfr:"𝔟",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bNot:"⫭",bnot:"⌐",Bopf:"𝔹",bopf:"𝕓",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxbox:"⧉",boxdl:"┐",boxdL:"╕",boxDl:"╖",boxDL:"╗",boxdr:"┌",boxdR:"╒",boxDr:"╓",boxDR:"╔",boxh:"─",boxH:"═",boxhd:"┬",boxHd:"╤",boxhD:"╥",boxHD:"╦",boxhu:"┴",boxHu:"╧",boxhU:"╨",boxHU:"╩",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxul:"┘",boxuL:"╛",boxUl:"╜",boxUL:"╝",boxur:"└",boxuR:"╘",boxUr:"╙",boxUR:"╚",boxv:"│",boxV:"║",boxvh:"┼",boxvH:"╪",boxVh:"╫",boxVH:"╬",boxvl:"┤",boxvL:"╡",boxVl:"╢",boxVL:"╣",boxvr:"├",boxvR:"╞",boxVr:"╟",boxVR:"╠",bprime:"‵",breve:"˘",Breve:"˘",brvbar:"¦",bscr:"𝒷",Bscr:"ℬ",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsolb:"⧅",bsol:"\\",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpE:"⪮",bumpe:"≏",Bumpeq:"≎",bumpeq:"≏",Cacute:"Ć",cacute:"ć",capand:"⩄",capbrcup:"⩉",capcap:"⩋",cap:"∩",Cap:"⋒",capcup:"⩇",capdot:"⩀",CapitalDifferentialD:"ⅅ",caps:"∩︀",caret:"⁁",caron:"ˇ",Cayleys:"ℭ",ccaps:"⩍",Ccaron:"Č",ccaron:"č",Ccedil:"Ç",ccedil:"ç",Ccirc:"Ĉ",ccirc:"ĉ",Cconint:"∰",ccups:"⩌",ccupssm:"⩐",Cdot:"Ċ",cdot:"ċ",cedil:"¸",Cedilla:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",CenterDot:"·",cfr:"𝔠",Cfr:"ℭ",CHcy:"Ч",chcy:"ч",check:"✓",checkmark:"✓",Chi:"Χ",chi:"χ",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",CircleDot:"⊙",circledR:"®",circledS:"Ⓢ",CircleMinus:"⊖",CirclePlus:"⊕",CircleTimes:"⊗",cir:"○",cirE:"⧃",cire:"≗",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",ClockwiseContourIntegral:"∲",CloseCurlyDoubleQuote:"”",CloseCurlyQuote:"’",clubs:"♣",clubsuit:"♣",colon:":",Colon:"∷",Colone:"⩴",colone:"≔",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",Congruent:"≡",conint:"∮",Conint:"∯",ContourIntegral:"∮",copf:"𝕔",Copf:"ℂ",coprod:"∐",Coproduct:"∐",copy:"©",COPY:"©",copysr:"℗",CounterClockwiseContourIntegral:"∳",crarr:"↵",cross:"✗",Cross:"⨯",Cscr:"𝒞",cscr:"𝒸",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cupbrcap:"⩈",cupcap:"⩆",CupCap:"≍",cup:"∪",Cup:"⋓",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dagger:"†",Dagger:"‡",daleth:"ℸ",darr:"↓",Darr:"↡",dArr:"⇓",dash:"‐",Dashv:"⫤",dashv:"⊣",dbkarow:"⤏",dblac:"˝",Dcaron:"Ď",dcaron:"ď",Dcy:"Д",dcy:"д",ddagger:"‡",ddarr:"⇊",DD:"ⅅ",dd:"ⅆ",DDotrahd:"⤑",ddotseq:"⩷",deg:"°",Del:"∇",Delta:"Δ",delta:"δ",demptyv:"⦱",dfisht:"⥿",Dfr:"𝔇",dfr:"𝔡",dHar:"⥥",dharl:"⇃",dharr:"⇂",DiacriticalAcute:"´",DiacriticalDot:"˙",DiacriticalDoubleAcute:"˝",DiacriticalGrave:"`",DiacriticalTilde:"˜",diam:"⋄",diamond:"⋄",Diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",DifferentialD:"ⅆ",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",DJcy:"Ђ",djcy:"ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",Dopf:"𝔻",dopf:"𝕕",Dot:"¨",dot:"˙",DotDot:"⃜",doteq:"≐",doteqdot:"≑",DotEqual:"≐",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",DoubleContourIntegral:"∯",DoubleDot:"¨",DoubleDownArrow:"⇓",DoubleLeftArrow:"⇐",DoubleLeftRightArrow:"⇔",DoubleLeftTee:"⫤",DoubleLongLeftArrow:"⟸",DoubleLongLeftRightArrow:"⟺",DoubleLongRightArrow:"⟹",DoubleRightArrow:"⇒",DoubleRightTee:"⊨",DoubleUpArrow:"⇑",DoubleUpDownArrow:"⇕",DoubleVerticalBar:"∥",DownArrowBar:"⤓",downarrow:"↓",DownArrow:"↓",Downarrow:"⇓",DownArrowUpArrow:"⇵",DownBreve:"̑",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",DownLeftRightVector:"⥐",DownLeftTeeVector:"⥞",DownLeftVectorBar:"⥖",DownLeftVector:"↽",DownRightTeeVector:"⥟",DownRightVectorBar:"⥗",DownRightVector:"⇁",DownTeeArrow:"↧",DownTee:"⊤",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",Dscr:"𝒟",dscr:"𝒹",DScy:"Ѕ",dscy:"ѕ",dsol:"⧶",Dstrok:"Đ",dstrok:"đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",DZcy:"Џ",dzcy:"џ",dzigrarr:"⟿",Eacute:"É",eacute:"é",easter:"⩮",Ecaron:"Ě",ecaron:"ě",Ecirc:"Ê",ecirc:"ê",ecir:"≖",ecolon:"≕",Ecy:"Э",ecy:"э",eDDot:"⩷",Edot:"Ė",edot:"ė",eDot:"≑",ee:"ⅇ",efDot:"≒",Efr:"𝔈",efr:"𝔢",eg:"⪚",Egrave:"È",egrave:"è",egs:"⪖",egsdot:"⪘",el:"⪙",Element:"∈",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",Emacr:"Ē",emacr:"ē",empty:"∅",emptyset:"∅",EmptySmallSquare:"◻",emptyv:"∅",EmptyVerySmallSquare:"▫",emsp13:" ",emsp14:" ",emsp:" ",ENG:"Ŋ",eng:"ŋ",ensp:" ",Eogon:"Ę",eogon:"ę",Eopf:"𝔼",eopf:"𝕖",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",Epsilon:"Ε",epsilon:"ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",Equal:"⩵",equals:"=",EqualTilde:"≂",equest:"≟",Equilibrium:"⇌",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erarr:"⥱",erDot:"≓",escr:"ℯ",Escr:"ℰ",esdot:"≐",Esim:"⩳",esim:"≂",Eta:"Η",eta:"η",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",euro:"€",excl:"!",exist:"∃",Exists:"∃",expectation:"ℰ",exponentiale:"ⅇ",ExponentialE:"ⅇ",fallingdotseq:"≒",Fcy:"Ф",fcy:"ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",Ffr:"𝔉",ffr:"𝔣",filig:"fi",FilledSmallSquare:"◼",FilledVerySmallSquare:"▪",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",Fopf:"𝔽",fopf:"𝕗",forall:"∀",ForAll:"∀",fork:"⋔",forkv:"⫙",Fouriertrf:"ℱ",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",Fscr:"ℱ",gacute:"ǵ",Gamma:"Γ",gamma:"γ",Gammad:"Ϝ",gammad:"ϝ",gap:"⪆",Gbreve:"Ğ",gbreve:"ğ",Gcedil:"Ģ",Gcirc:"Ĝ",gcirc:"ĝ",Gcy:"Г",gcy:"г",Gdot:"Ġ",gdot:"ġ",ge:"≥",gE:"≧",gEl:"⪌",gel:"⋛",geq:"≥",geqq:"≧",geqslant:"⩾",gescc:"⪩",ges:"⩾",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",Gfr:"𝔊",gfr:"𝔤",gg:"≫",Gg:"⋙",ggg:"⋙",gimel:"ℷ",GJcy:"Ѓ",gjcy:"ѓ",gla:"⪥",gl:"≷",glE:"⪒",glj:"⪤",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gnE:"≩",gneq:"⪈",gneqq:"≩",gnsim:"⋧",Gopf:"𝔾",gopf:"𝕘",grave:"`",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",Gscr:"𝒢",gscr:"ℊ",gsim:"≳",gsime:"⪎",gsiml:"⪐",gtcc:"⪧",gtcir:"⩺",gt:">",GT:">",Gt:"≫",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",HARDcy:"Ъ",hardcy:"ъ",harrcir:"⥈",harr:"↔",hArr:"⇔",harrw:"↭",Hat:"^",hbar:"ℏ",Hcirc:"Ĥ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",Hfr:"ℌ",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",Hopf:"ℍ",horbar:"―",HorizontalLine:"─",hscr:"𝒽",Hscr:"ℋ",hslash:"ℏ",Hstrok:"Ħ",hstrok:"ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",Iacute:"Í",iacute:"í",ic:"",Icirc:"Î",icirc:"î",Icy:"И",icy:"и",Idot:"İ",IEcy:"Е",iecy:"е",iexcl:"¡",iff:"⇔",ifr:"𝔦",Ifr:"ℑ",Igrave:"Ì",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",IJlig:"IJ",ijlig:"ij",Imacr:"Ī",imacr:"ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",Im:"ℑ",imof:"⊷",imped:"Ƶ",Implies:"⇒",incare:"℅",in:"∈",infin:"∞",infintie:"⧝",inodot:"ı",intcal:"⊺",int:"∫",Int:"∬",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"",InvisibleTimes:"",IOcy:"Ё",iocy:"ё",Iogon:"Į",iogon:"į",Iopf:"𝕀",iopf:"𝕚",Iota:"Ι",iota:"ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",Iscr:"ℐ",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"",Itilde:"Ĩ",itilde:"ĩ",Iukcy:"І",iukcy:"і",Iuml:"Ï",iuml:"ï",Jcirc:"Ĵ",jcirc:"ĵ",Jcy:"Й",jcy:"й",Jfr:"𝔍",jfr:"𝔧",jmath:"ȷ",Jopf:"𝕁",jopf:"𝕛",Jscr:"𝒥",jscr:"𝒿",Jsercy:"Ј",jsercy:"ј",Jukcy:"Є",jukcy:"є",Kappa:"Κ",kappa:"κ",kappav:"ϰ",Kcedil:"Ķ",kcedil:"ķ",Kcy:"К",kcy:"к",Kfr:"𝔎",kfr:"𝔨",kgreen:"ĸ",KHcy:"Х",khcy:"х",KJcy:"Ќ",kjcy:"ќ",Kopf:"𝕂",kopf:"𝕜",Kscr:"𝒦",kscr:"𝓀",lAarr:"⇚",Lacute:"Ĺ",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",Lambda:"Λ",lambda:"λ",lang:"⟨",Lang:"⟪",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",larrb:"⇤",larrbfs:"⤟",larr:"←",Larr:"↞",lArr:"⇐",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",latail:"⤙",lAtail:"⤛",lat:"⪫",late:"⪭",lates:"⪭︀",lbarr:"⤌",lBarr:"⤎",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",Lcaron:"Ľ",lcaron:"ľ",Lcedil:"Ļ",lcedil:"ļ",lceil:"⌈",lcub:"{",Lcy:"Л",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",lE:"≦",LeftAngleBracket:"⟨",LeftArrowBar:"⇤",leftarrow:"←",LeftArrow:"←",Leftarrow:"⇐",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVectorBar:"⥙",LeftDownVector:"⇃",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",LeftRightArrow:"↔",Leftrightarrow:"⇔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTeeArrow:"↤",LeftTee:"⊣",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangleBar:"⧏",LeftTriangle:"⊲",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVectorBar:"⥘",LeftUpVector:"↿",LeftVectorBar:"⥒",LeftVector:"↼",lEg:"⪋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",lescc:"⪨",les:"⩽",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",Lfr:"𝔏",lfr:"𝔩",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",LJcy:"Љ",ljcy:"љ",llarr:"⇇",ll:"≪",Ll:"⋘",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",Lmidot:"Ŀ",lmidot:"ŀ",lmoustache:"⎰",lmoust:"⎰",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lnE:"≨",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",LongLeftArrow:"⟵",Longleftarrow:"⟸",longleftrightarrow:"⟷",LongLeftRightArrow:"⟷",Longleftrightarrow:"⟺",longmapsto:"⟼",longrightarrow:"⟶",LongRightArrow:"⟶",Longrightarrow:"⟹",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",Lopf:"𝕃",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",Lscr:"ℒ",lsh:"↰",Lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",Lstrok:"Ł",lstrok:"ł",ltcc:"⪦",ltcir:"⩹",lt:"<",LT:"<",Lt:"≪",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",Map:"⤅",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",Mcy:"М",mcy:"м",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",mfr:"𝔪",mho:"℧",micro:"µ",midast:"*",midcir:"⫰",mid:"∣",middot:"·",minusb:"⊟",minus:"−",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",Mopf:"𝕄",mopf:"𝕞",mp:"∓",mscr:"𝓂",Mscr:"ℳ",mstpos:"∾",Mu:"Μ",mu:"μ",multimap:"⊸",mumap:"⊸",nabla:"∇",Nacute:"Ń",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natural:"♮",naturals:"ℕ",natur:"♮",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",Ncaron:"Ň",ncaron:"ň",Ncedil:"Ņ",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",Ncy:"Н",ncy:"н",ndash:"–",nearhk:"⤤",nearr:"↗",neArr:"⇗",nearrow:"↗",ne:"≠",nedot:"≐̸",NegativeMediumSpace:"",NegativeThickSpace:"",NegativeThinSpace:"",NegativeVeryThinSpace:"",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",Nfr:"𝔑",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",nGt:"≫⃒",ngt:"≯",ngtr:"≯",nGtv:"≫̸",nharr:"↮",nhArr:"⇎",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",NJcy:"Њ",njcy:"њ",nlarr:"↚",nlArr:"⇍",nldr:"‥",nlE:"≦̸",nle:"≰",nleftarrow:"↚",nLeftarrow:"⇍",nleftrightarrow:"↮",nLeftrightarrow:"⇎",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nLt:"≪⃒",nlt:"≮",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"",NonBreakingSpace:" ",nopf:"𝕟",Nopf:"ℕ",Not:"⫬",not:"¬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangleBar:"⧏̸",NotLeftTriangle:"⋪",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangleBar:"⧐̸",NotRightTriangle:"⋫",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",nparallel:"∦",npar:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",nprec:"⊀",npreceq:"⪯̸",npre:"⪯̸",nrarrc:"⤳̸",nrarr:"↛",nrArr:"⇏",nrarrw:"↝̸",nrightarrow:"↛",nRightarrow:"⇏",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",Nscr:"𝒩",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",Ntilde:"Ñ",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",Nu:"Ν",nu:"ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nvdash:"⊬",nvDash:"⊭",nVdash:"⊮",nVDash:"⊯",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwarr:"↖",nwArr:"⇖",nwarrow:"↖",nwnear:"⤧",Oacute:"Ó",oacute:"ó",oast:"⊛",Ocirc:"Ô",ocirc:"ô",ocir:"⊚",Ocy:"О",ocy:"о",odash:"⊝",Odblac:"Ő",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",OElig:"Œ",oelig:"œ",ofcir:"⦿",Ofr:"𝔒",ofr:"𝔬",ogon:"˛",Ograve:"Ò",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",Omacr:"Ō",omacr:"ō",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",omid:"⦶",ominus:"⊖",Oopf:"𝕆",oopf:"𝕠",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",orarr:"↻",Or:"⩔",or:"∨",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",Oscr:"𝒪",oscr:"ℴ",Oslash:"Ø",oslash:"ø",osol:"⊘",Otilde:"Õ",otilde:"õ",otimesas:"⨶",Otimes:"⨷",otimes:"⊗",Ouml:"Ö",ouml:"ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",para:"¶",parallel:"∥",par:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",Pcy:"П",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",Pfr:"𝔓",pfr:"𝔭",Phi:"Φ",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",Pi:"Π",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plus:"+",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",popf:"𝕡",Popf:"ℙ",pound:"£",prap:"⪷",Pr:"⪻",pr:"≺",prcue:"≼",precapprox:"⪷",prec:"≺",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",pre:"⪯",prE:"⪳",precsim:"≾",prime:"′",Prime:"″",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportional:"∝",Proportion:"∷",propto:"∝",prsim:"≾",prurel:"⊰",Pscr:"𝒫",pscr:"𝓅",Psi:"Ψ",psi:"ψ",puncsp:" ",Qfr:"𝔔",qfr:"𝔮",qint:"⨌",qopf:"𝕢",Qopf:"ℚ",qprime:"⁗",Qscr:"𝒬",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',QUOT:'"',rAarr:"⇛",race:"∽̱",Racute:"Ŕ",racute:"ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",Rang:"⟫",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarr:"→",Rarr:"↠",rArr:"⇒",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",Rarrtl:"⤖",rarrtl:"↣",rarrw:"↝",ratail:"⤚",rAtail:"⤜",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rBarr:"⤏",RBarr:"⤐",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",Rcaron:"Ř",rcaron:"ř",Rcedil:"Ŗ",rcedil:"ŗ",rceil:"⌉",rcub:"}",Rcy:"Р",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",Re:"ℜ",rect:"▭",reg:"®",REG:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",Rfr:"ℜ",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",Rho:"Ρ",rho:"ρ",rhov:"ϱ",RightAngleBracket:"⟩",RightArrowBar:"⇥",rightarrow:"→",RightArrow:"→",Rightarrow:"⇒",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVectorBar:"⥕",RightDownVector:"⇂",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTeeArrow:"↦",RightTee:"⊢",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangleBar:"⧐",RightTriangle:"⊳",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVectorBar:"⥔",RightUpVector:"↾",RightVectorBar:"⥓",RightVector:"⇀",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"",rmoustache:"⎱",rmoust:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",Ropf:"ℝ",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",rscr:"𝓇",Rscr:"ℛ",rsh:"↱",Rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",Sacute:"Ś",sacute:"ś",sbquo:"‚",scap:"⪸",Scaron:"Š",scaron:"š",Sc:"⪼",sc:"≻",sccue:"≽",sce:"⪰",scE:"⪴",Scedil:"Ş",scedil:"ş",Scirc:"Ŝ",scirc:"ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",Scy:"С",scy:"с",sdotb:"⊡",sdot:"⋅",sdote:"⩦",searhk:"⤥",searr:"↘",seArr:"⇘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",Sfr:"𝔖",sfr:"𝔰",sfrown:"⌢",sharp:"♯",SHCHcy:"Щ",shchcy:"щ",SHcy:"Ш",shcy:"ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",SOFTcy:"Ь",softcy:"ь",solbar:"⌿",solb:"⧄",sol:"/",Sopf:"𝕊",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",square:"□",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squ:"□",squf:"▪",srarr:"→",Sscr:"𝒮",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",Star:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",Sub:"⋐",subdot:"⪽",subE:"⫅",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",subset:"⊂",Subset:"⋐",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succapprox:"⪸",succ:"≻",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",sum:"∑",Sum:"∑",sung:"♪",sup1:"¹",sup2:"²",sup3:"³",sup:"⊃",Sup:"⋑",supdot:"⪾",supdsub:"⫘",supE:"⫆",supe:"⊇",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",supset:"⊃",Supset:"⋑",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swarr:"↙",swArr:"⇙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",Tau:"Τ",tau:"τ",tbrk:"⎴",Tcaron:"Ť",tcaron:"ť",Tcedil:"Ţ",tcedil:"ţ",Tcy:"Т",tcy:"т",tdot:"⃛",telrec:"⌕",Tfr:"𝔗",tfr:"𝔱",there4:"∴",therefore:"∴",Therefore:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:" ",ThinSpace:" ",thinsp:" ",thkap:"≈",thksim:"∼",THORN:"Þ",thorn:"þ",tilde:"˜",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",timesbar:"⨱",timesb:"⊠",times:"×",timesd:"⨰",tint:"∭",toea:"⤨",topbot:"⌶",topcir:"⫱",top:"⊤",Topf:"𝕋",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",TRADE:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",Tscr:"𝒯",tscr:"𝓉",TScy:"Ц",tscy:"ц",TSHcy:"Ћ",tshcy:"ћ",Tstrok:"Ŧ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",Uacute:"Ú",uacute:"ú",uarr:"↑",Uarr:"↟",uArr:"⇑",Uarrocir:"⥉",Ubrcy:"Ў",ubrcy:"ў",Ubreve:"Ŭ",ubreve:"ŭ",Ucirc:"Û",ucirc:"û",Ucy:"У",ucy:"у",udarr:"⇅",Udblac:"Ű",udblac:"ű",udhar:"⥮",ufisht:"⥾",Ufr:"𝔘",ufr:"𝔲",Ugrave:"Ù",ugrave:"ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",Umacr:"Ū",umacr:"ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",uogon:"ų",Uopf:"𝕌",uopf:"𝕦",UpArrowBar:"⤒",uparrow:"↑",UpArrow:"↑",Uparrow:"⇑",UpArrowDownArrow:"⇅",updownarrow:"↕",UpDownArrow:"↕",Updownarrow:"⇕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",upsi:"υ",Upsi:"ϒ",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",UpTeeArrow:"↥",UpTee:"⊥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",Uring:"Ů",uring:"ů",urtri:"◹",Uscr:"𝒰",uscr:"𝓊",utdot:"⋰",Utilde:"Ũ",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",Uuml:"Ü",uuml:"ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",vArr:"⇕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vBar:"⫨",Vbar:"⫫",vBarv:"⫩",Vcy:"В",vcy:"в",vdash:"⊢",vDash:"⊨",Vdash:"⊩",VDash:"⊫",Vdashl:"⫦",veebar:"⊻",vee:"∨",Vee:"⋁",veeeq:"≚",vellip:"⋮",verbar:"|",Verbar:"‖",vert:"|",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",Vopf:"𝕍",vopf:"𝕧",vprop:"∝",vrtri:"⊳",Vscr:"𝒱",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",Vvdash:"⊪",vzigzag:"⦚",Wcirc:"Ŵ",wcirc:"ŵ",wedbar:"⩟",wedge:"∧",Wedge:"⋀",wedgeq:"≙",weierp:"℘",Wfr:"𝔚",wfr:"𝔴",Wopf:"𝕎",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",Wscr:"𝒲",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",Xfr:"𝔛",xfr:"𝔵",xharr:"⟷",xhArr:"⟺",Xi:"Ξ",xi:"ξ",xlarr:"⟵",xlArr:"⟸",xmap:"⟼",xnis:"⋻",xodot:"⨀",Xopf:"𝕏",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrarr:"⟶",xrArr:"⟹",Xscr:"𝒳",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",Yacute:"Ý",yacute:"ý",YAcy:"Я",yacy:"я",Ycirc:"Ŷ",ycirc:"ŷ",Ycy:"Ы",ycy:"ы",yen:"¥",Yfr:"𝔜",yfr:"𝔶",YIcy:"Ї",yicy:"ї",Yopf:"𝕐",yopf:"𝕪",Yscr:"𝒴",yscr:"𝓎",YUcy:"Ю",yucy:"ю",yuml:"ÿ",Yuml:"Ÿ",Zacute:"Ź",zacute:"ź",Zcaron:"Ž",zcaron:"ž",Zcy:"З",zcy:"з",Zdot:"Ż",zdot:"ż",zeetrf:"ℨ",ZeroWidthSpace:"",Zeta:"Ζ",zeta:"ζ",zfr:"𝔷",Zfr:"ℨ",ZHcy:"Ж",zhcy:"ж",zigrarr:"⇝",zopf:"𝕫",Zopf:"ℤ",Zscr:"𝒵",zscr:"𝓏",zwj:"",zwnj:""},Yc={Aacute:"Á",aacute:"á",Acirc:"Â",acirc:"â",acute:"´",AElig:"Æ",aelig:"æ",Agrave:"À",agrave:"à",amp:"&",AMP:"&",Aring:"Å",aring:"å",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",brvbar:"¦",Ccedil:"Ç",ccedil:"ç",cedil:"¸",cent:"¢",copy:"©",COPY:"©",curren:"¤",deg:"°",divide:"÷",Eacute:"É",eacute:"é",Ecirc:"Ê",ecirc:"ê",Egrave:"È",egrave:"è",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",frac12:"½",frac14:"¼",frac34:"¾",gt:">",GT:">",Iacute:"Í",iacute:"í",Icirc:"Î",icirc:"î",iexcl:"¡",Igrave:"Ì",igrave:"ì",iquest:"¿",Iuml:"Ï",iuml:"ï",laquo:"«",lt:"<",LT:"<",macr:"¯",micro:"µ",middot:"·",nbsp:" ",not:"¬",Ntilde:"Ñ",ntilde:"ñ",Oacute:"Ó",oacute:"ó",Ocirc:"Ô",ocirc:"ô",Ograve:"Ò",ograve:"ò",ordf:"ª",ordm:"º",Oslash:"Ø",oslash:"ø",Otilde:"Õ",otilde:"õ",Ouml:"Ö",ouml:"ö",para:"¶",plusmn:"±",pound:"£",quot:'"',QUOT:'"',raquo:"»",reg:"®",REG:"®",sect:"§",shy:"",sup1:"¹",sup2:"²",sup3:"³",szlig:"ß",THORN:"Þ",thorn:"þ",times:"×",Uacute:"Ú",uacute:"ú",Ucirc:"Û",ucirc:"û",Ugrave:"Ù",ugrave:"ù",uml:"¨",Uuml:"Ü",uuml:"ü",Yacute:"Ý",yacute:"ý",yen:"¥",yuml:"ÿ"},eu={amp:"&",apos:"'",gt:">",lt:"<",quot:'"'},tu={},nu=Wc&&Wc.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(tu,"__esModule",{value:!0});var ru=nu({0:65533,128:8364,130:8218,131:402,132:8222,133:8230,134:8224,135:8225,136:710,137:8240,138:352,139:8249,140:338,142:381,145:8216,146:8217,147:8220,148:8221,149:8226,150:8211,151:8212,152:732,153:8482,154:353,155:8250,156:339,158:382,159:376}),ou=String.fromCodePoint||function(e){var t="";return e>65535&&(e-=65536,t+=String.fromCharCode(e>>>10&1023|55296),e=56320|1023&e),t+=String.fromCharCode(e)};tu.default=function(e){return e>=55296&&e<=57343||e>1114111?"�":(e in ru.default&&(e=ru.default[e]),ou(e))};var iu=Wc&&Wc.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(Xc,"__esModule",{value:!0}),Xc.decodeHTML=Xc.decodeHTMLStrict=Xc.decodeXML=void 0;var su=iu(Qc),au=iu(Yc),lu=iu(eu),cu=iu(tu),uu=/&(?:[a-zA-Z0-9]+|#[xX][\da-fA-F]+|#\d+);/g;function du(e){var t=hu(e);return function(e){return String(e).replace(uu,t)}}Xc.decodeXML=du(lu.default),Xc.decodeHTMLStrict=du(su.default);var pu=function(e,t){return e1?Mu(e):e.charCodeAt(0)).toString(16).toUpperCase()+";"}var Eu=new RegExp(vu.source+"|"+Tu.source,"g");function Nu(e){return function(t){return t.replace(Eu,(function(t){return e[t]||Su(t)}))}}fu.escape=function(e){return e.replace(Eu,Su)},fu.escapeUTF8=function(e){return e.replace(vu,Su)},function(e){Object.defineProperty(e,"__esModule",{value:!0}),e.decodeXMLStrict=e.decodeHTML5Strict=e.decodeHTML4Strict=e.decodeHTML5=e.decodeHTML4=e.decodeHTMLStrict=e.decodeHTML=e.decodeXML=e.encodeHTML5=e.encodeHTML4=e.escapeUTF8=e.escape=e.encodeNonAsciiHTML=e.encodeHTML=e.encodeXML=e.encode=e.decodeStrict=e.decode=void 0;var t=Xc,n=fu;e.decode=function(e,n){return(!n||n<=0?t.decodeXML:t.decodeHTML)(e)},e.decodeStrict=function(e,n){return(!n||n<=0?t.decodeXML:t.decodeHTMLStrict)(e)},e.encode=function(e,t){return(!t||t<=0?n.encodeXML:n.encodeHTML)(e)};var r=fu;Object.defineProperty(e,"encodeXML",{enumerable:!0,get:function(){return r.encodeXML}}),Object.defineProperty(e,"encodeHTML",{enumerable:!0,get:function(){return r.encodeHTML}}),Object.defineProperty(e,"encodeNonAsciiHTML",{enumerable:!0,get:function(){return r.encodeNonAsciiHTML}}),Object.defineProperty(e,"escape",{enumerable:!0,get:function(){return r.escape}}),Object.defineProperty(e,"escapeUTF8",{enumerable:!0,get:function(){return r.escapeUTF8}}),Object.defineProperty(e,"encodeHTML4",{enumerable:!0,get:function(){return r.encodeHTML}}),Object.defineProperty(e,"encodeHTML5",{enumerable:!0,get:function(){return r.encodeHTML}});var o=Xc;Object.defineProperty(e,"decodeXML",{enumerable:!0,get:function(){return o.decodeXML}}),Object.defineProperty(e,"decodeHTML",{enumerable:!0,get:function(){return o.decodeHTML}}),Object.defineProperty(e,"decodeHTMLStrict",{enumerable:!0,get:function(){return o.decodeHTMLStrict}}),Object.defineProperty(e,"decodeHTML4",{enumerable:!0,get:function(){return o.decodeHTML}}),Object.defineProperty(e,"decodeHTML5",{enumerable:!0,get:function(){return o.decodeHTML}}),Object.defineProperty(e,"decodeHTML4Strict",{enumerable:!0,get:function(){return o.decodeHTMLStrict}}),Object.defineProperty(e,"decodeHTML5Strict",{enumerable:!0,get:function(){return o.decodeHTMLStrict}}),Object.defineProperty(e,"decodeXMLStrict",{enumerable:!0,get:function(){return o.decodeXML}})}(Zc);var Ou="&(?:#x[a-f0-9]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,31});",Du=/[\\&]/,Au="[!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]",Lu=new RegExp("\\\\"+Au+"|"+Ou,"gi"),Iu=new RegExp('[&<>"]',"g"),Ru=function(e){return 92===e.charCodeAt(0)?e.charAt(1):Zc.decodeHTML(e)};function Pu(e){return Du.test(e)?e.replace(Lu,Ru):e}function Bu(e){try{return Kc(e)}catch(t){return e}}function Fu(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case'"':return""";default:return e}}function Hu(e){return Iu.test(e)?e.replace(Iu,Fu):e}function zu(e,t){for(var n=[],r=0;r`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>",fd=""+pd+"\\s*[>]",md=new RegExp("^(?:<[A-Za-z][A-Za-z0-9-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>|[A-Za-z][A-Za-z0-9-]*\\s*[>]|\x3c!----\x3e|\x3c!--(?:-?[^>-])(?:-?[^-])*--\x3e|[<][?].*?[?][>]|]*>|)","i");if(String.fromCodePoint)dd=function(e){try{return String.fromCodePoint(e)}catch(e){if(e instanceof RangeError)return String.fromCharCode(65533);throw e}};else{var gd=String.fromCharCode,vd=Math.floor;dd=function(){for(var e=[],t=0;t1114111||vd(c)!==c)return String.fromCharCode(65533);c<=65535?i.push(c):(n=55296+((c-=65536)>>10),r=c%1024+56320,i.push(n,r)),(s+1===a||i.length>o)&&(l+=gd.apply(void 0,i),i.length=0)}return l}}var yd=dd;function bd(e){var t=/\)+$/.exec(e);if(t){for(var n=0,r=0,o=e;r?@\[\]\\^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/),Ld=new RegExp('^(?:"('+Dd+'|[^"\\x00])*"|\'('+Dd+"|[^'\\x00])*'|\\(("+Dd+"|[^()\\x00])*\\))"),Id=/^(?:<(?:[^<>\n\\\x00]|\\.)*>)/,Rd=new RegExp("^"+Au),Pd=new RegExp("^"+Ou,"i"),Bd=/`+/,Fd=/^`+/,Hd=/\.\.\./g,zd=/--+/g,qd=/^<([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])?)*)>/,Vd=/^<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/i,jd=/^ *(?:\n *)?/,$d=/^[ \t\n\x0b\x0c\x0d]/,_d=/^\s/,Ud=/ *$/,Wd=/^ */,Jd=/^ *(?:\n|$)/,Gd=/^\[(?:[^\\\[\]]|\\.){0,1000}\]/,Kd=/^[^\n`\[\]\\!<&*_'"~$]+/m,Zd=function(){function e(e){this.subject="",this.delimiters=null,this.brackets=null,this.pos=0,this.lineStartNum=0,this.lineIdx=0,this.lineOffsets=[0],this.linePosOffset=0,this.refMap={},this.refLinkCandidateMap={},this.refDefCandidateMap={},this.options=e}return e.prototype.sourcepos=function(e,t){var n=this.linePosOffset+this.lineOffsets[this.lineIdx],r=this.lineStartNum+this.lineIdx,o=[r,e+n];return"number"==typeof t?[o,[r,t+n]]:o},e.prototype.nextLine=function(){this.lineIdx+=1,this.linePosOffset=-this.pos},e.prototype.match=function(e){var t=e.exec(this.subject.slice(this.pos));return null===t?null:(this.pos+=t.index+t[0].length,t[0])},e.prototype.peek=function(){return this.pos1){var l=xd(a);this.lineIdx+=a.length-1,this.linePosOffset=-(this.pos-l.length-n.length),s[1]=this.sourcepos(this.pos),i=a.join(" ")}var c=id("code",s);return i.length>0&&null!==i.match(/[^ ]/)&&" "==i[0]&&" "==i[i.length-1]?c.literal=i.slice(1,i.length-1):c.literal=i,c.tickCount=n.length,e.appendChild(c),!0}return this.pos=o,e.appendChild(ud(n,this.sourcepos(t,this.pos-1))),!0},e.prototype.parseBackslash=function(e){var t,n=this.subject;this.pos+=1;var r=this.pos;return 10===this.peek()?(this.pos+=1,t=id("linebreak",this.sourcepos(this.pos-1,this.pos)),e.appendChild(t),this.nextLine()):Rd.test(n.charAt(this.pos))?(e.appendChild(ud(n.charAt(this.pos),this.sourcepos(r,this.pos))),this.pos+=1):e.appendChild(ud("\\",this.sourcepos(r,r))),!0},e.prototype.parseAutolink=function(e){var t,n,r,o=this.pos+1;return(t=this.match(qd))?(n=t.slice(1,t.length-1),(r=id("link",this.sourcepos(o,this.pos))).destination=Bu("mailto:"+n),r.title="",r.appendChild(ud(n,this.sourcepos(o+1,this.pos-1))),e.appendChild(r),!0):!!(t=this.match(Vd))&&(n=t.slice(1,t.length-1),(r=id("link",this.sourcepos(o,this.pos))).destination=Bu(n),r.title="",r.appendChild(ud(n,this.sourcepos(o+1,this.pos-1))),e.appendChild(r),!0)},e.prototype.parseHtmlTag=function(e){var t=this.pos+1,n=this.match(md);if(null===n)return!1;var r=id("htmlInline",this.sourcepos(t,this.pos));return r.literal=n,e.appendChild(r),!0},e.prototype.scanDelims=function(e){var t=0,n=this.pos;if(e===Ed||e===Nd)t++,this.pos++;else for(;this.peek()===e;)t++,this.pos++;if(0===t||t<2&&(e===Sd||e===Od))return this.pos=n,null;var r,o=0===n?"\n":this.subject.charAt(n-1),i=this.peek();r=-1===i?"\n":yd(i);var s,a,l=_d.test(r),c=Ad.test(r),u=_d.test(o),d=Ad.test(o),p=!l&&(!c||u||d),h=!u&&(!d||l||c);return 95===e?(s=p&&(!h||d),a=h&&(!p||c)):e===Ed||e===Nd?(s=p&&!h,a=h):e===Od?(s=!l,a=!u):(s=p,a=h),this.pos=n,{numdelims:t,canOpen:s,canClose:a}},e.prototype.handleDelim=function(e,t){var n=this.scanDelims(e);if(!n)return!1;var r=n.numdelims,o=this.pos+1;this.pos+=r;var i=ud(e===Ed?"’":e===Nd?"“":this.subject.slice(o-1,this.pos),this.sourcepos(o,this.pos));return t.appendChild(i),(n.canOpen||n.canClose)&&(this.options.smart||e!==Ed&&e!==Nd)&&(this.delimiters={cc:e,numdelims:r,origdelims:r,node:i,previous:this.delimiters,next:null,canOpen:n.canOpen,canClose:n.canClose},this.delimiters.previous&&(this.delimiters.previous.next=this.delimiters)),!0},e.prototype.removeDelimiter=function(e){null!==e.previous&&(e.previous.next=e.next),null===e.next?this.delimiters=e.previous:e.next.previous=e.previous},e.prototype.removeDelimitersBetween=function(e,t){e.next!==t&&(e.next=t,t.previous=e)},e.prototype.processEmphasis=function(e){var t,n,r,o,i,s,a,l=!1,c=((t={})[95]=[e,e,e],t[42]=[e,e,e],t[39]=[e],t[34]=[e],t[126]=[e],t[36]=[e],t);for(r=this.delimiters;null!==r&&r.previous!==e;)r=r.previous;for(;null!==r;){var u=r.cc,d=95===u||42===u;if(r.canClose){for(n=r.previous,a=!1;null!==n&&n!==e&&n!==c[u][d?r.origdelims%3:0];){if(l=d&&(r.canOpen||n.canClose)&&r.origdelims%3!=0&&(n.origdelims+r.origdelims)%3==0,n.cc===r.cc&&n.canOpen&&!l){a=!0;break}n=n.previous}if(o=r,d||u===Sd||u===Od)if(a){if(n){var p=r.numdelims>=2&&n.numdelims>=2?2:1,h=d?0:1;i=n.node,s=r.node;var f=d?1===p?"emph":"strong":"strike";u===Od&&(f="customInline");var m=id(f),g=i.sourcepos[1],v=s.sourcepos[0];m.sourcepos=[[g[0],g[1]-p+1],[v[0],v[1]+p-1]],i.sourcepos[1][1]-=p,s.sourcepos[0][1]+=p,i.literal=i.literal.slice(p),s.literal=s.literal.slice(p),n.numdelims-=p,r.numdelims-=p;for(var y=i.next,b=void 0;y&&y!==s;)b=y.next,y.unlink(),m.appendChild(y),y=b;if(u===Od){var w=m.firstChild,k=w.literal||"",x=k.split(/\s/)[0];m.info=x,k.length<=x.length?w.unlink():(w.sourcepos[0][1]+=x.length,w.literal=k.replace(x+" ",""))}if(i.insertAfter(m),this.removeDelimitersBetween(n,r),n.numdelims<=h&&(0===n.numdelims&&i.unlink(),this.removeDelimiter(n)),r.numdelims<=h){0===r.numdelims&&s.unlink();var C=r.next;this.removeDelimiter(r),r=C}}}else r=r.next;else u===Ed?(r.node.literal="’",a&&(n.node.literal="‘"),r=r.next):u===Nd&&(r.node.literal="”",a&&(n.node.literal="“"),r=r.next);a||(c[u][d?o.origdelims%3:0]=o.previous,o.canOpen||this.removeDelimiter(o))}else r=r.next}for(;null!==this.delimiters&&this.delimiters!==e;)this.removeDelimiter(this.delimiters)},e.prototype.parseLinkTitle=function(){var e=this.match(Ld);return null===e?null:Pu(e.substr(1,e.length-2))},e.prototype.parseLinkDestination=function(){var e=this.match(Id);if(null===e){if(60===this.peek())return null;for(var t=this.pos,n=0,r=void 0;-1!==(r=this.peek());)if(92===r&&Rd.test(this.subject.charAt(this.pos+1)))this.pos+=1,-1!==this.peek()&&(this.pos+=1);else if(40===r)this.pos+=1,n+=1;else if(41===r){if(n<1)break;this.pos+=1,n-=1}else{if(null!==$d.exec(yd(r)))break;this.pos+=1}return this.pos===t&&41!==r||0!==n?null:Bu(Pu(e=this.subject.substr(t,this.pos-t)))}return Bu(Pu(e.substr(1,e.length-2)))},e.prototype.parseLinkLabel=function(){var e=this.match(Gd);return null===e||e.length>1001?0:e.length},e.prototype.parseOpenBracket=function(e){var t=this.pos;this.pos+=1;var n=ud("[",this.sourcepos(this.pos,this.pos));return e.appendChild(n),this.addBracket(n,t,!1),!0},e.prototype.parseBang=function(e){var t=this.pos;if(this.pos+=1,91===this.peek()){this.pos+=1;var n=ud("![",this.sourcepos(this.pos-1,this.pos));e.appendChild(n),this.addBracket(n,t+1,!0)}else{n=ud("!",this.sourcepos(this.pos,this.pos));e.appendChild(n)}return!0},e.prototype.parseCloseBracket=function(e){var t=null,n=null,r=!1;this.pos+=1;var o=this.pos,i=this.brackets;if(null===i)return e.appendChild(ud("]",this.sourcepos(o,o))),!0;if(!i.active)return e.appendChild(ud("]",this.sourcepos(o,o))),this.removeBracket(),!0;var s=i.image,a=this.pos;40===this.peek()&&(this.pos++,this.spnl()&&null!==(t=this.parseLinkDestination())&&this.spnl()&&($d.test(this.subject.charAt(this.pos-1))&&(n=this.parseLinkTitle()),1)&&this.spnl()&&41===this.peek()?(this.pos+=1,r=!0):this.pos=a);var l="";if(!r){var c=this.pos,u=this.parseLinkLabel();if(u>2?l=this.subject.slice(c,c+u):i.bracketAfter||(l=this.subject.slice(i.index,o)),0===u&&(this.pos=a),l){l=Cd(l);var d=this.refMap[l];d&&(t=d.destination,n=d.title,r=!0)}}if(r){var p=id(s?"image":"link");p.destination=t,p.title=n||"",p.sourcepos=[i.startpos,this.sourcepos(this.pos)];for(var h=i.node.next,f=void 0;h;)f=h.next,h.unlink(),p.appendChild(h),h=f;if(e.appendChild(p),this.processEmphasis(i.previousDelimiter),this.removeBracket(),i.node.unlink(),!s)for(i=this.brackets;null!==i;)i.image||(i.active=!1),i=i.previous;return this.options.referenceDefinition&&(this.refLinkCandidateMap[e.id]={node:e,refLabel:l}),!0}return this.removeBracket(),this.pos=o,e.appendChild(ud("]",this.sourcepos(o,o))),this.options.referenceDefinition&&(this.refLinkCandidateMap[e.id]={node:e,refLabel:l}),!0},e.prototype.addBracket=function(e,t,n){null!==this.brackets&&(this.brackets.bracketAfter=!0),this.brackets={node:e,startpos:this.sourcepos(t+(n?0:1)),previous:this.brackets,previousDelimiter:this.delimiters,index:t,image:n,active:!0}},e.prototype.removeBracket=function(){this.brackets&&(this.brackets=this.brackets.previous)},e.prototype.parseEntity=function(e){var t,n=this.pos+1;return!!(t=this.match(Pd))&&(e.appendChild(ud(Zc.decodeHTML(t),this.sourcepos(n,this.pos))),!0)},e.prototype.parseString=function(e){var t,n=this.pos+1;if(t=this.match(Kd)){if(this.options.smart){var r=t.replace(Hd,"…").replace(zd,(function(e){var t=0,n=0;return e.length%3==0?n=e.length/3:e.length%2==0?t=e.length/2:e.length%3==2?(t=1,n=(e.length-2)/3):(t=2,n=(e.length-4)/3),zu("—",n)+zu("–",t)}));e.appendChild(ud(r,this.sourcepos(n,this.pos)))}else{var o=ud(t,this.sourcepos(n,this.pos));e.appendChild(o)}return!0}return!1},e.prototype.parseNewline=function(e){this.pos+=1;var t=e.lastChild;if(t&&"text"===t.type&&" "===t.literal[t.literal.length-1]){var n=" "===t.literal[t.literal.length-2],r=t.literal.length;t.literal=t.literal.replace(Ud,"");var o=r-t.literal.length;t.sourcepos[1][1]-=o,e.appendChild(id(n?"linebreak":"softbreak",this.sourcepos(this.pos-o,this.pos)))}else e.appendChild(id("softbreak",this.sourcepos(this.pos,this.pos)));return this.nextLine(),this.match(Wd),!0},e.prototype.parseReference=function(e,t){if(!this.options.referenceDefinition)return 0;this.subject=e.stringContent,this.pos=0;var n=null,r=this.pos,o=this.parseLinkLabel();if(0===o)return 0;var i=this.subject.substr(0,o);if(58!==this.peek())return this.pos=r,0;this.pos++,this.spnl();var s=this.parseLinkDestination();if(null===s)return this.pos=r,0;var a=this.pos;this.spnl(),this.pos!==a&&(n=this.parseLinkTitle()),null===n&&(n="",this.pos=a);var l=!0;if(null===this.match(Jd)&&(""===n?l=!1:(n="",this.pos=a,l=null!==this.match(Jd))),!l)return this.pos=r,0;var c=Cd(i);if(""===c)return this.pos=r,0;var u=this.getReferenceDefSourcepos(e);e.sourcepos[0][0]=u[1][0]+1;var d=id("refDef",u);return d.title=n,d.dest=s,d.label=c,e.insertBefore(d),t[c]?this.refDefCandidateMap[d.id]=d:t[c]=$p(d),this.pos-r},e.prototype.mergeTextNodes=function(e){for(var t,n=[];t=e.next();){var r=t.entering,o=t.node;if(r&&"text"===o.type)n.push(o);else if(1===n.length)n=[];else if(n.length>1){var i=n[0],s=n[n.length-1];i.sourcepos&&s.sourcepos&&(i.sourcepos[1]=s.sourcepos[1]),i.next=s.next,i.next&&(i.next.prev=i);for(var a=1;as&&d.push(ud(o.substring(s,m[0]),u(s,m[0]-1)));var y=id("link",u.apply(void 0,m));y.appendChild(ud(v,u.apply(void 0,m))),y.destination=g,y.extendedAutolink=!0,d.push(y),s=m[1]+1}s0&&rp(tp(n,e.offset));)e.advanceOffset(1,!0),o--;return 0},finalize:function(e,t){if(null!==t.stringContent){var n=t.stringContent,r=n.indexOf("\n"),o=n.slice(0,r),i=n.slice(r+1),s=o.match(/^(\s*)(.*)/);t.info=Pu(s[2].trim()),t.literal=i,t.stringContent=null}},canContain:function(){return!1},acceptsLines:!0},sp={continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!0},ap={continue:function(){return 0},finalize:function(e,t){for(var n=t.firstChild;n;){if(ep(n)&&n.next){t.listData.tight=!1;break}for(var r=n.firstChild;r;){if(ep(r)&&(n.next||r.next)){t.listData.tight=!1;break}r=r.next}n=n.next}},canContain:function(e){return"item"===e},acceptsLines:!1},lp={continue:function(e,t){if(e.blank){if(null===t.firstChild)return 1;e.advanceNextNonspace()}else{if(!(e.indent>=t.listData.markerOffset+t.listData.padding))return 1;e.advanceOffset(t.listData.markerOffset+t.listData.padding,!0)}return 0},finalize:function(e,t){if(t.firstChild&&"paragraph"===t.firstChild.type){var n=t.firstChild,r=n.stringContent.match(Xd);if(r){var o=r[0].length;n.stringContent=n.stringContent.substring(o-1),n.sourcepos[0][1]+=o,n.lineOffsets[0]+=o,t.listData.task=!0,t.listData.checked=/[xX]/.test(r[1])}}},canContain:function(e){return"item"!==e},acceptsLines:!1},cp={continue:function(e,t){var n=e.currentLine,r=e.indent;if(t.isFenced){var o=r<=3&&n.charAt(e.nextNonspace)===t.fenceChar&&n.slice(e.nextNonspace).match(Yd);if(o&&o[0].length>=t.fenceLength)return e.lastLineLength=e.offset+r+o[0].length,e.finalize(t,e.lineNumber),2;for(var i=t.fenceOffset;i>0&&rp(tp(n,e.offset));)e.advanceOffset(1,!0),i--}else if(r>=4)e.advanceOffset(4,!0);else{if(!e.blank)return 1;e.advanceNextNonspace()}return 0},finalize:function(e,t){var n;if(null!==t.stringContent){if(t.isFenced){var r=t.stringContent,o=r.indexOf("\n"),i=r.slice(0,o),s=r.slice(o+1),a=i.match(/^(\s*)(.*)/);t.infoPadding=a[1].length,t.info=Pu(a[2].trim()),t.literal=s}else t.literal=null===(n=t.stringContent)||void 0===n?void 0:n.replace(/(\n *)+$/,"\n");t.stringContent=null}},canContain:function(){return!1},acceptsLines:!0},up={continue:function(e){return e.blank?1:0},finalize:function(e,t){if(null!==t.stringContent){for(var n,r=!1;91===tp(t.stringContent,0)&&(n=e.inlineParser.parseReference(t,e.refMap));)t.stringContent=t.stringContent.slice(n),r=!0;r&&np(t.stringContent)&&t.unlink()}},canContain:function(){return!1},acceptsLines:!0},dp={document:{continue:function(){return 0},finalize:function(){},canContain:function(e){return"item"!==e},acceptsLines:!1},list:ap,blockQuote:{continue:function(e){var t=e.currentLine;return e.indented||62!==tp(t,e.nextNonspace)?1:(e.advanceNextNonspace(),e.advanceOffset(1,!1),rp(tp(t,e.offset))&&e.advanceOffset(1,!0),0)},finalize:function(){},canContain:function(e){return"item"!==e},acceptsLines:!1},item:lp,heading:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},thematicBreak:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},codeBlock:cp,htmlBlock:{continue:function(e,t){return!e.blank||6!==t.htmlBlockType&&7!==t.htmlBlockType?0:1},finalize:function(e,t){var n;t.literal=(null===(n=t.stringContent)||void 0===n?void 0:n.replace(/(\n *)+$/,""))||null,t.stringContent=null},canContain:function(){return!1},acceptsLines:!0},paragraph:up,table:{continue:function(){return 0},finalize:function(){},canContain:function(e){return"tableHead"===e||"tableBody"===e},acceptsLines:!1},tableBody:{continue:function(){return 0},finalize:function(){},canContain:function(e){return"tableRow"===e},acceptsLines:!1},tableHead:{continue:function(){return 1},finalize:function(){},canContain:function(e){return"tableRow"===e||"tableDelimRow"===e},acceptsLines:!1},tableRow:{continue:function(){return 1},finalize:function(){},canContain:function(e){return"tableCell"===e},acceptsLines:!1},tableCell:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},tableDelimRow:{continue:function(){return 1},finalize:function(){},canContain:function(e){return"tableDelimCell"===e},acceptsLines:!1},tableDelimCell:{continue:function(){return 1},finalize:function(){},canContain:function(){return!1},acceptsLines:!1},refDef:sp,customBlock:ip,frontMatter:sp};function pp(e){for(var t=0,n=0,r=[],o=0;o|$)/i,/^/,/\?>/,/>/,/\]\]>/],Dp=/^[#`~*+_=<>0-9-;$]/,Ap=/\r\n|\n|\r/;function Lp(){return id("document",[[1,1],[0,0]])}var Ip={smart:!1,tagFilter:!1,extendedAutolinks:!1,disallowedHtmlBlockTags:[],referenceDefinition:!1,disallowDeepHeading:!1,customParser:null,frontMatter:!1},Rp=function(){function e(e){this.options=_c(_c({},Ip),e),this.doc=Lp(),this.tip=this.doc,this.oldtip=this.doc,this.lineNumber=0,this.offset=0,this.column=0,this.nextNonspace=0,this.nextNonspaceColumn=0,this.indent=0,this.currentLine="",this.indented=!1,this.blank=!1,this.partiallyConsumedTab=!1,this.allClosed=!0,this.lastMatchedContainer=this.doc,this.refMap={},this.refLinkCandidateMap={},this.refDefCandidateMap={},this.lastLineLength=0,this.lines=[],this.options.frontMatter&&(dp.frontMatter=Np,Mp.unshift(Ep)),this.inlineParser=new Zd(this.options)}return e.prototype.advanceOffset=function(e,t){void 0===t&&(t=!1);for(var n,r,o,i=this.currentLine;e>0&&(o=i[this.offset]);)"\t"===o?(n=4-this.column%4,t?(this.partiallyConsumedTab=n>e,r=n>e?e:n,this.column+=r,this.offset+=this.partiallyConsumedTab?0:1,e-=r):(this.partiallyConsumedTab=!1,this.column+=n,this.offset+=1,e-=1)):(this.partiallyConsumedTab=!1,this.offset+=1,this.column+=1,e-=1)},e.prototype.advanceNextNonspace=function(){this.offset=this.nextNonspace,this.column=this.nextNonspaceColumn,this.partiallyConsumedTab=!1},e.prototype.findNextNonspace=function(){for(var e,t=this.currentLine,n=this.offset,r=this.column;""!==(e=t.charAt(n));)if(" "===e)n++,r++;else{if("\t"!==e)break;n++,r+=4-r%4}this.blank="\n"===e||"\r"===e||""===e,this.nextNonspace=n,this.nextNonspaceColumn=r,this.indent=this.nextNonspaceColumn-this.column,this.indented=this.indent>=4},e.prototype.addLine=function(){if(this.partiallyConsumedTab){this.offset+=1;var e=4-this.column%4;this.tip.stringContent+=zu(" ",e)}this.tip.lineOffsets?this.tip.lineOffsets.push(this.offset):this.tip.lineOffsets=[this.offset],this.tip.stringContent+=this.currentLine.slice(this.offset)+"\n"},e.prototype.addChild=function(e,t){for(;!dp[this.tip.type].canContain(e);)this.finalize(this.tip,this.lineNumber-1);var n=t+1,r=id(e,[[this.lineNumber,n],[0,0]]);return r.stringContent="",this.tip.appendChild(r),this.tip=r,r},e.prototype.closeUnmatchedBlocks=function(){if(!this.allClosed){for(;this.oldtip!==this.lastMatchedContainer;){var e=this.oldtip.parent;this.finalize(this.oldtip,this.lineNumber-1),this.oldtip=e}this.allClosed=!0}},e.prototype.finalize=function(e,t){var n=e.parent;e.open=!1,e.sourcepos[1]=[t,this.lastLineLength],dp[e.type].finalize(this,e),this.tip=n},e.prototype.processInlines=function(e){var t,n=this.options.customParser,r=e.walker();for(this.inlineParser.refMap=this.refMap,this.inlineParser.refLinkCandidateMap=this.refLinkCandidateMap,this.inlineParser.refDefCandidateMap=this.refDefCandidateMap,this.inlineParser.options=this.options;t=r.next();){var o=t.node,i=t.entering,s=o.type;n&&n[s]&&n[s](o,{entering:i,options:this.options}),i||"paragraph"!==s&&"heading"!==s&&("tableCell"!==s||o.ignored)||this.inlineParser.parse(o)}},e.prototype.incorporateLine=function(e){var t=this.doc;this.oldtip=this.tip,this.offset=0,this.column=0,this.blank=!1,this.partiallyConsumedTab=!1,this.lineNumber+=1,-1!==e.indexOf("\0")&&(e=e.replace(/\0/g,"�")),this.currentLine=e;for(var n,r=!0;(n=t.lastChild)&&n.open;){switch(t=n,this.findNextNonspace(),dp[t.type].continue(this,t)){case 0:break;case 1:r=!1;break;case 2:return void(this.lastLineLength=e.length);default:throw new Error("continue returned illegal value, must be 0, 1, or 2")}if(!r){t=t.parent;break}}this.allClosed=t===this.oldtip,this.lastMatchedContainer=t;for(var o="paragraph"!==t.type&&dp[t.type].acceptsLines,i=Mp.length;!o;){if(this.findNextNonspace(),"table"!==t.type&&"tableBody"!==t.type&&"paragraph"!==t.type&&!this.indented&&!Dp.test(e.slice(this.nextNonspace))){this.advanceNextNonspace();break}for(var s=0;s=1&&t.htmlBlockType<=5&&Op[t.htmlBlockType].test(this.currentLine.slice(this.offset))&&(this.lastLineLength=e.length,this.finalize(t,this.lineNumber))):this.offsett[0]?-1:e[1]t[1]?-1:0}function Bp(e,t){var n=e[0];return 1===Pp(e[1],t)?1:-1===Pp(n,t)?-1:0}function Fp(e,t){for(var n=0,r=t;nt?-1:0}function zp(e,t){for(var n=e.firstChild;n;){var r=Hp(n.sourcepos,t);if(0===r)return n;if(-1===r)return n.prev||n;n=n.next}return e.lastChild}function qp(e){return function(e){return _u[e]}(e)||null}function Vp(e,t,n){if(void 0===n&&(n=null),t)for(var r=t.walker();t&&t!==n;){e(t);var o=r.next();if(!o)break;t=o.node}}var jp=/\r\n|\n|\r/;function $p(e){return{id:e.id,title:e.title,sourcepos:e.sourcepos,unlinked:!1,destination:e.dest}}var _p=function(){function e(e,t){this.refMap={},this.refLinkCandidateMap={},this.refDefCandidateMap={},this.referenceDefinition=!!(null==t?void 0:t.referenceDefinition),this.parser=new Rp(t),this.parser.setRefMaps(this.refMap,this.refLinkCandidateMap,this.refDefCandidateMap),this.eventHandlerMap={change:[]},e=e||"",this.lineTexts=e.split(jp),this.root=this.parser.parse(e,this.lineTexts)}return e.prototype.updateLineTexts=function(e,t,n){var r,o=e[0],i=e[1],s=t[0],a=t[1],l=n.split(jp),c=l.length,u=this.lineTexts[o-1],d=this.lineTexts[s-1];l[0]=u.slice(0,i-1)+l[0],l[c-1]=l[c-1]+d.slice(a-1);var p=s-o+1;return(r=this.lineTexts).splice.apply(r,Uc([o-1,p],l)),c-p},e.prototype.updateRootNodeState=function(){if(1===this.lineTexts.length&&""===this.lineTexts[0])return this.root.lastLineBlank=!0,void(this.root.sourcepos=[[1,1],[1,0]]);this.root.lastChild&&(this.root.lastLineBlank=this.root.lastChild.lastLineBlank);for(var e=this.lineTexts,t=e.length-1;""===e[t];)t-=1;e.length-2>t&&(t+=1),this.root.sourcepos[1]=[t+1,e[t].length]},e.prototype.replaceRangeNodes=function(e,t,n){e?(Fp(e,n),function(e,t){if(e.parent===t.parent&&e!==t){for(var n=e.next;n&&n!==t;){for(var r=n.next,o=0,i=["parent","prev","next"];o=0;n-=1)e.prependChild(t[n])}(this.root,n)},e.prototype.getNodeRange=function(e,t){var n=zp(this.root,e[0]),r=zp(this.root,t[0]);return r&&r.next&&t[0]+1===r.next.sourcepos[0][0]&&(r=r.next),[n,r]},e.prototype.trigger=function(e,t){this.eventHandlerMap[e].forEach((function(e){e(t)}))},e.prototype.extendEndLine=function(e){for(;""===this.lineTexts[e];)e+=1;return e},e.prototype.parseRange=function(e,t,n,r){var o;e&&e.prev&&(ad(e.prev)&&function(e){var t=e.match(/^[ \t]+/);if(t&&(t[0].length>=2||/\t/.test(t[0])))return!0;var n=t?e.slice(t.length):e;return xp.test(n)||Cp.test(n)}(this.lineTexts[n-1])||function(e){return"table"===e.type}(e.prev)&&(!np(o=this.lineTexts[n-1])&&-1!==o.indexOf("|")))&&(n=(e=e.prev).sourcepos[0][0]);for(var i=this.lineTexts.slice(n-1,r),s=this.parser.partialParseStart(n,i),a=t?t.next:this.root.firstChild,l=s.lastChild,c=l&&sd(l)&&l.open,u=l&&cd(l)&&l.open,d=l&&ad(l);(c||u)&&a||d&&a&&("list"===a.type||a.sourcepos[0][1]>=2);){var p=this.extendEndLine(a.sourcepos[1][0]);this.parser.partialParseExtends(this.lineTexts.slice(r,p)),e||(e=t),t=a,r=p,a=a.next}return this.parser.partialParseFinish(),{newNodes:function(e){for(var t=[],n=e.firstChild;n;)t.push(n),n=n.next;return t}(s),extStartNode:e,extEndNode:t}},e.prototype.getRemovedNodeRange=function(e,t){return!e||e&&ld(e)||t&&ld(t)?null:{id:[e.id,t.id],line:[e.sourcepos[0][0]-1,t.sourcepos[1][0]-1]}},e.prototype.markDeletedRefMap=function(e,t){var n=this;if(!Md(this.refMap)){var r=function(e){if(ld(e)){var t=n.refMap[e.label];t&&e.id===t.id&&(t.unlinked=!0)}};e&&Vp(r,e.parent,t),t&&Vp(r,t)}},e.prototype.replaceWithNewRefDefState=function(e){var t=this;if(!Md(this.refMap)){var n=function(e){if(ld(e)){var n=e.label,r=t.refMap[n];r&&!r.unlinked||(t.refMap[n]=$p(e))}};e.forEach((function(e){Vp(n,e)}))}},e.prototype.replaceWithRefDefCandidate=function(){var e=this;Md(this.refDefCandidateMap)||Td(this.refDefCandidateMap,(function(t,n){var r=n.label,o=n.sourcepos,i=e.refMap[r];(!i||i.unlinked||i.sourcepos[0][0]>o[0][0])&&(e.refMap[r]=$p(n))}))},e.prototype.getRangeWithRefDef=function(e,t,n,r,o){if(this.referenceDefinition&&!Md(this.refMap)){var i=zp(this.root,e-1),s=zp(this.root,t+1);i&&ld(i)&&i!==n&&i!==r&&(e=(n=i).sourcepos[0][0]),s&&ld(s)&&s!==n&&s!==r&&(r=s,t=this.extendEndLine(r.sourcepos[1][0]+o))}return[n,r,e,t]},e.prototype.parse=function(e,t,n){void 0===n&&(n=0);var r=this.getNodeRange(e,t),o=r[0],i=r[1],s=o?Math.min(o.sourcepos[0][0],e[0]):e[0],a=this.extendEndLine((i?Math.max(i.sourcepos[1][0],t[0]):t[0])+n),l=this.parseRange.apply(this,this.getRangeWithRefDef(s,a,o,i,n)),c=l.newNodes,u=l.extStartNode,d=l.extEndNode,p=this.getRemovedNodeRange(u,d),h=d?d.next:this.root.firstChild;return this.referenceDefinition?(this.markDeletedRefMap(u,d),this.replaceRangeNodes(u,d,c),this.replaceWithNewRefDefState(c)):this.replaceRangeNodes(u,d,c),{nodes:c,removedNodeRange:p,nextNode:h}},e.prototype.parseRefLink=function(){var e=this,t=[];return Md(this.refMap)||Td(this.refMap,(function(n,r){r.unlinked&&delete e.refMap[n],Td(e.refLinkCandidateMap,(function(r,o){var i=o.node;o.refLabel===n&&t.push(e.parse(i.sourcepos[0],i.sourcepos[1]))}))})),t},e.prototype.removeUnlinkedCandidate=function(){Md(this.refDefCandidateMap)||[this.refLinkCandidateMap,this.refDefCandidateMap].forEach((function(e){Td(e,(function(t){(function(e){var t=qp(e);if(!t)return!0;for(;t&&"document"!==t.type;){if(!t.parent&&!t.prev&&!t.next)return!0;t=t.parent}return!1})(t)&&delete e[t]}))}))},e.prototype.editMarkdown=function(e,t,n){var r=this.updateLineTexts(e,t,n),o=this.parse(e,t,r),i=function(e){for(var t=[],n=1;n]*>)","ig");function Wp(e){return Up.test(e)?e.replace(Up,(function(e,t){return"<"+t})):e}var Jp={heading:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"h"+e.level,outerNewLine:!0}},text:function(e){return{type:"text",content:e.literal}},softbreak:function(e,t){return{type:"html",content:t.options.softbreak}},linebreak:function(){return{type:"html",content:" \n"}},emph:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"em"}},strong:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"strong"}},paragraph:function(e,t){var n,r=t.entering,o=null===(n=e.parent)||void 0===n?void 0:n.parent;return o&&"list"===o.type&&o.listData.tight?null:{type:r?"openTag":"closeTag",tagName:"p",outerNewLine:!0}},thematicBreak:function(){return{type:"openTag",tagName:"hr",outerNewLine:!0,selfClose:!0}},blockQuote:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"blockquote",outerNewLine:!0,innerNewLine:!0}},list:function(e,t){var n=t.entering,r=e.listData,o=r.type,i=r.start,s="bullet"===o?"ul":"ol",a={};return"ol"===s&&null!==i&&1!==i&&(a.start=i.toString()),{type:n?"openTag":"closeTag",tagName:s,attributes:a,outerNewLine:!0}},item:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"li",outerNewLine:!0}},htmlInline:function(e,t){return{type:"html",content:t.options.tagFilter?Wp(e.literal):e.literal}},htmlBlock:function(e,t){var n=t.options,r=n.tagFilter?Wp(e.literal):e.literal;return n.nodeId?[{type:"openTag",tagName:"div",outerNewLine:!0},{type:"html",content:r},{type:"closeTag",tagName:"div",outerNewLine:!0}]:{type:"html",content:r,outerNewLine:!0}},code:function(e){return[{type:"openTag",tagName:"code"},{type:"text",content:e.literal},{type:"closeTag",tagName:"code"}]},codeBlock:function(e){var t=e.info,n=t?t.split(/\s+/):[],r=[];return n.length>0&&n[0].length>0&&r.push("language-"+Hu(n[0])),[{type:"openTag",tagName:"pre",outerNewLine:!0},{type:"openTag",tagName:"code",classNames:r},{type:"text",content:e.literal},{type:"closeTag",tagName:"code"},{type:"closeTag",tagName:"pre",outerNewLine:!0}]},link:function(e,t){if(t.entering){var n=e,r=n.title,o=n.destination;return{type:"openTag",tagName:"a",attributes:_c({href:Hu(o)},r&&{title:Hu(r)})}}return{type:"closeTag",tagName:"a"}},image:function(e,t){var n=t.getChildrenText,r=t.skipChildren,o=e,i=o.title,s=o.destination;return r(),{type:"openTag",tagName:"img",selfClose:!0,attributes:_c({src:Hu(s),alt:n(e)},i&&{title:Hu(i)})}},customBlock:function(e,t,n){var r=e.info.trim().toLowerCase(),o=n[r];if(o)try{return o(e,t)}catch(e){console.warn("[@toast-ui/editor] - The error occurred when "+r+" block node was parsed in markdown renderer: "+e)}return[{type:"openTag",tagName:"div",outerNewLine:!0},{type:"text",content:e.literal},{type:"closeTag",tagName:"div",outerNewLine:!0}]},frontMatter:function(e){return[{type:"openTag",tagName:"div",outerNewLine:!0,attributes:{style:"white-space: pre; display: none;"}},{type:"text",content:e.literal},{type:"closeTag",tagName:"div",outerNewLine:!0}]},customInline:function(e,t,n){var r=e,o=r.info,i=r.firstChild,s=o.trim().toLowerCase(),a=n[s],l=t.entering;if(a)try{return a(e,t)}catch(e){console.warn("[@toast-ui/editor] - The error occurred when "+s+" inline node was parsed in markdown renderer: "+e)}return l?[{type:"openTag",tagName:"span"},{type:"text",content:"$$"+o+(i?" ":"")}]:[{type:"text",content:"$$"},{type:"closeTag",tagName:"span"}]}},Gp={strike:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"del"}},item:function(e,t){var n=t.entering,r=e.listData,o=r.checked,i=r.task;if(n){var s={type:"openTag",tagName:"li",outerNewLine:!0};return i?[s,{type:"openTag",tagName:"input",selfClose:!0,attributes:_c(_c({},o&&{checked:""}),{disabled:"",type:"checkbox"})},{type:"text",content:" "}]:s}return{type:"closeTag",tagName:"li",outerNewLine:!0}},table:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"table",outerNewLine:!0}},tableHead:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"thead",outerNewLine:!0}},tableBody:function(e,t){return{type:t.entering?"openTag":"closeTag",tagName:"tbody",outerNewLine:!0}},tableRow:function(e,t){if(t.entering)return{type:"openTag",tagName:"tr",outerNewLine:!0};var n=[];if(e.lastChild)for(var r=e.parent.parent.columns.length,o=e.lastChild.endIdx+1;o0&&this.buffer.push(' class="'+r.join(" ")+'"'),o&&Object.keys(o).forEach((function(e){var n=o[e];t.buffer.push(" "+e+'="'+n+'"')})),e.selfClose&&this.buffer.push(" /"),this.buffer.push(">")},e.prototype.generateCloseTagString=function(e){var t=e.tagName;this.buffer.push(""+t+">")},e.prototype.addNewLine=function(){this.buffer.length&&"\n"!==xd(xd(this.buffer))&&this.buffer.push("\n")},e.prototype.addOuterNewLine=function(e){e.outerNewLine&&this.addNewLine()},e.prototype.addInnerNewLine=function(e){e.innerNewLine&&this.addNewLine()},e.prototype.renderTextNode=function(e){this.buffer.push(Hu(e.content))},e.prototype.renderRawHtmlNode=function(e){this.addOuterNewLine(e),this.buffer.push(e.content),this.addOuterNewLine(e)},e.prototype.renderElementNode=function(e){"openTag"===e.type?(this.addOuterNewLine(e),this.generateOpenTagString(e),e.selfClose?this.addOuterNewLine(e):this.addInnerNewLine(e)):(this.addInnerNewLine(e),this.generateCloseTagString(e),this.addOuterNewLine(e))},e}(),Qp=n(368),Yp=n.n(Qp),eh=["iframe","embed"],th=[];function nh(e){Ue(eh,e)&&th.push(e.toLowerCase())}function rh(e,t){return Yp().sanitize(e,o({ADD_TAGS:th,ADD_ATTR:["rel","target","hreflang","type"],FORBID_TAGS:["input","script","textarea","form","button","select","meta","style","link","title","object","base"]},t))}function oh(e,t){return e.literal.replace(new RegExp("(<\\s*"+t+"[^>]*>)|("+t+"\\s*[>])","ig"),"").trim()}function ih(e){var t=(e=e.match(ga)[0]).match(new RegExp(ha,"g"));return t?t.reduce((function(e,t){var n=t.trim().split("="),r=n[0],o=n.slice(1);return o.length&&(e[r]=o.join("=").replace(/'|"/g,"").trim()),e}),{}):{}}function sh(e){return vi()(e.attributes).reduce((function(e,t){return e[t.nodeName]=t.nodeValue,e}),{})}function ah(e,t,n,r){var o=r.getToDOMNode(t)(e),i=n(o.outerHTML),s=document.createElement("div");return s.innerHTML=i,{dom:o=s.firstChild,htmlAttrs:sh(o)}}var lh={htmlBlock:function(e,t,n){return{atom:!0,content:"block+",group:"block",attrs:{htmlAttrs:{default:{}},childrenHTML:{default:""},htmlBlock:{default:!0}},parseDOM:[{tag:e,getAttrs:function(e){return{htmlAttrs:sh(e),childrenHTML:e.innerHTML}}}],toDOM:function(r){var o=ah(r,e,t,n),s=o.dom,a=o.htmlAttrs;return a.class=a.class?a.class+" html-block":"html-block",i([e,a],vi()(s.childNodes))}}},htmlInline:function(e,t,n){return{attrs:{htmlAttrs:{default:{}},htmlInline:{default:!0}},parseDOM:[{tag:e,getAttrs:function(e){return{htmlAttrs:sh(e)}}}],toDOM:function(r){var o=ah(r,e,t,n).htmlAttrs;return[e,o,0]}}}};var ch=/^\s*<\s*\//,uh={paragraph:function(e,t){var n=t.entering,r=t.origin;return t.options.nodeId?{type:n?"openTag":"closeTag",outerNewLine:!0,tagName:"p"}:r()},softbreak:function(e){return{type:"html",content:e.prev&&"htmlInline"===e.prev.type&&/ /.test(e.prev.literal)?"\n":" \n"}},item:function(e,t){if(t.entering){var n={},r=[];return e.listData.task&&(n["data-task"]="",r.push("task-list-item"),e.listData.checked&&(r.push("checked"),n["data-task-checked"]="")),{type:"openTag",tagName:"li",classNames:r,attributes:n,outerNewLine:!0}}return{type:"closeTag",tagName:"li",outerNewLine:!0}},code:function(e){return[{type:"openTag",tagName:"code",attributes:{"data-backticks":String(e.tickCount)}},{type:"text",content:e.literal},{type:"closeTag",tagName:"code"}]},codeBlock:function(e){var t=e,n=t.fenceLength,r=t.info,o=r?r.split(/\s+/):[],i=[],s={};if(n>3&&(s["data-backticks"]=n),o.length>0&&o[0].length>0){var a=o[0];i.push("lang-"+a),s["data-language"]=a}return[{type:"openTag",tagName:"pre",classNames:i},{type:"openTag",tagName:"code",attributes:s},{type:"text",content:e.literal},{type:"closeTag",tagName:"code"},{type:"closeTag",tagName:"pre"}]},customInline:function(e,t){var n=t.origin,r=t.entering,o=t.skipChildren,i=e.info;return-1!==i.indexOf("widget")&&r?(o(),[{type:"openTag",tagName:"span",classNames:["tui-widget"]},{type:"html",content:Ys(i,oa(e)).outerHTML},{type:"closeTag",tagName:"span"}]):n()}};function dh(e,t){var n=o({},uh);return e&&(n.link=function(t,n){var r=n.entering,i=(0,n.origin)();return r&&(i.attributes=o(o({},i.attributes),e)),i}),t&&Object.keys(t).forEach((function(e){var r=n[e],i=t[e];r&&Ga()(i)?n[e]=function(e,t){var n=o({},t);return n.origin=function(){return r(e,t)},i(e,n)}:Ue(["htmlBlock","htmlInline"],e)&&!Ga()(i)?n[e]=function(e,t){var n=e.literal.match(ga);if(n){var r=n[0],s=n[1],a=n[3],l=(s||a).toLowerCase(),c=i[l],u=oh(e,l);if(c){var d=o({},e);return d.attrs=ih(r),d.childrenHTML=u,d.type=l,t.entering=!ch.test(e.literal),c(d,t)}}return t.origin()}:n[e]=i})),n}var ph=["list","item","blockQuote"],hh=["UL","OL","BLOCKQUOTE"];function fh(e,t){var n,r=e.child(t);return!r.childCount||1===r.childCount&&!(null===(n=r.firstChild.text)||void 0===n?void 0:n.trim())}function mh(e,t,n){var r=Is(t)-1,o=Rs(t)-1,i=n[r].getBoundingClientRect(),s=n[o].offsetTop-n[r].offsetTop+n[o].clientHeight;return{height:s<=0?n[r].clientHeight:s+gh(e,n,Math.min(o+1,e.childCount-1)),rect:i}}function gh(e,t,n){for(var r=e.childCount-1,o=0;n<=r&&fh(e,n);)o+=t[n].clientHeight,n+=1;return o}function vh(e,t){for(var n=0;e&&e!==t&&(Ue(hh,e.tagName)||(n+=e.offsetTop),e.offsetParent!==t.offsetParent);)e=e.parentElement;return n}function yh(e,t,n){return e&&t>n+e.offsetTop?yh(e.nextElementSibling,t,n)||e:null}function bh(e,t){for(var n=e.querySelector('[data-nodeid="'+t.id+'"]');!n||Fs(t);)t=t.parent,n=e.querySelector('[data-nodeid="'+t.id+'"]');return function(e){var t=e.mdNode,n=e.el;for(;(Ue(ph,t.type)||"table"===t.type)&&t.firstChild;)t=t.firstChild,n=n.firstElementChild;return{mdNode:t,el:n}}({mdNode:t,el:n})}var wh={};function kh(e){e&&(delete wh[Number(e.getAttribute("data-nodeid"))],vi()(e.children).forEach((function(e){kh(e)})))}function xh(e,t,n){var r,o=wh[r=n]&&wh[r].height,i=function(e){return wh[e]&&wh[e].offsetTop}(n),s=o||e.clientHeight,a=i||vh(e,t)||e.offsetTop;return o||function(e,t){wh[e]=wh[e]||{},wh[e].height=t}(n,s),i||function(e,t){wh[e]=wh[e]||{},wh[e].offsetTop=t}(n,a),{nodeHeight:s,offsetTop:a}}var Ch=xa("md-preview-highlight");var Th=function(){function e(e,t){var n=document.createElement("div");this.el=n,this.eventEmitter=e,this.isViewer=!!t.isViewer,this.el.className=xa("md-preview");var r=t.linkAttributes,o=t.customHTMLRenderer,i=t.sanitizer,s=t.highlight,a=void 0!==s&&s;this.renderer=new Xp({gfm:!0,nodeId:!0,convertors:dh(r,o)}),this.cursorNodeId=null,this.sanitizer=i,this.initEvent(a),this.initContentSection(),this.isViewer&&(this.previewContent.style.overflowWrap="break-word")}return e.prototype.initContentSection=function(){this.previewContent=Ea('
'),this.isViewer||this.el.appendChild(this.previewContent)},e.prototype.toggleActive=function(e){Sa(this.el,"active",e)},e.prototype.initEvent=function(e){var t=this;this.eventEmitter.listen("updatePreview",this.update.bind(this)),this.isViewer||(e&&(this.eventEmitter.listen("changeToolbarState",(function(e){var n=e.mdNode,r=e.cursorPos;t.updateCursorNode(n,r)})),this.eventEmitter.listen("blur",(function(){t.removeHighlight()}))),Vc()(this.el,"scroll",(function(e){t.eventEmitter.emit("scroll","preview",function(e,t){for(var n=t,r=null;n;){var o=n.firstElementChild;if(!o)break;r=n,n=yh(o,e,vh(n,t))}var i=n||r;return i===t?null:i}(e.target.scrollTop,t.previewContent))})),this.eventEmitter.listen("changePreviewTabPreview",(function(){return t.toggleActive(!0)})),this.eventEmitter.listen("changePreviewTabWrite",(function(){return t.toggleActive(!1)})))},e.prototype.removeHighlight=function(){if(this.cursorNodeId){var e=this.getElementByNodeId(this.cursorNodeId);e&&Ce()(e,Ch)}},e.prototype.updateCursorNode=function(e,t){e&&("tableRow"===(e=js(e,(function(e){return!function(e){switch(e.type){case"code":case"text":case"emph":case"strong":case"strike":case"link":case"image":case"htmlInline":case"linebreak":case"softbreak":case"customInline":return!0;default:return!1}}(e)}))).type?e=function(e,t){for(var n=e.firstChild;n&&n.next&&!(Ps(n.next)>t+1);)n=n.next;return n}(e,t[1]):"tableBody"===e.type&&(e=null));var n=e?e.id:null;if(this.cursorNodeId!==n){var r=this.getElementByNodeId(this.cursorNodeId),o=this.getElementByNodeId(n);r&&Ce()(r,Ch),o&&ke()(o,Ch),this.cursorNodeId=n}},e.prototype.getElementByNodeId=function(e){return e?this.previewContent.querySelector('[data-nodeid="'+e+'"]'):null},e.prototype.update=function(e){var t=this;e.forEach((function(e){return t.replaceRangeNodes(e)})),this.eventEmitter.emit("afterPreviewRender",this)},e.prototype.replaceRangeNodes=function(e){var t=this,n=e.nodes,r=e.removedNodeRange,o=this.previewContent,i=this.eventEmitter.emitReduce("beforePreviewRender",this.sanitizer(n.map((function(e){return t.renderer.render(e)})).join("")));if(r){var s=r.id,a=s[0],l=s[1],c=this.getElementByNodeId(a),u=this.getElementByNodeId(l);if(c){c.insertAdjacentHTML("beforebegin",i);for(var d=c;d&&d!==u;){var p=d.nextElementSibling;Ma(d),kh(d),d=p}(null==d?void 0:d.parentNode)&&(Ma(d),kh(d))}}else o.insertAdjacentHTML("afterbegin",i)},e.prototype.getRenderer=function(){return this.renderer},e.prototype.destroy=function(){zc()(this.el,"scroll"),this.el=null},e.prototype.getElement=function(){return this.el},e.prototype.getHTML=function(){return La(this.previewContent.innerHTML)},e.prototype.setHTML=function(e){this.previewContent.innerHTML=e},e.prototype.setHeight=function(e){be()(this.el,{height:e+"px"})},e.prototype.setMinHeight=function(e){be()(this.el,{minHeight:e+"px"})},e}(),Mh=Th;function Sh(e,t){for(var n=e.depth;n;){var r=e.node(n);if(t(r,n))return{node:r,depth:n,offset:n>0?e.before(n):0};n-=1}return null}function Eh(e){return!!Sh(e,(function(e){var t=e.type;return"listItem"===t.name||"bulletList"===t.name||"orderedList"===t.name}))}function Nh(e){return!!Sh(e,(function(e){var t=e.type;return"tableHeadCell"===t.name||"tableBodyCell"===t.name}))}function Oh(e){return Sh(e,(function(e){return"listItem"===e.type.name}))}function Dh(e){return{tag:e,getAttrs:function(e){var t=e.getAttribute("data-raw-html");return o({},t&&{rawHTML:t})}}}function Ah(e){return Object.keys(e).reduce((function(t,n){return"rawHTML"!==n&&e[n]&&(t[n="className"===n?"class":n]=e[n]),t}),{})}function Lh(e){return{tag:e,getAttrs:function(e){return["rawHTML","colspan","rowspan","extended"].reduce((function(t,n){var r="rawHTML"===n?"data-raw-html":n,o=e.getAttribute(r);return o&&(t[n]=Ue(["rawHTML","extended"],n)?o:Number(o)),t}),{})}}}function Ih(e){var t=e.htmlAttrs,n=e.classNames;return o(o({},t),{class:n?n.join(" "):null})}function Rh(e,t,n,r){var o=t.$from,i=t.$to,s=t.depth,a=t,l=!1;if(s>=2&&o.node(s-1).type.compatibleContent(n)&&0===t.startIndex&&o.index(s-1)){var c=e.doc.resolve(t.start-2);a=new I(c,c,s),t.endIndex=0;p-=1)u=d.from(n[p].type.create(n[p].attrs,u));e.step(new wt(i-(r?2:0),s,i,s,new v(u,0,0),n.length,!0));var h=0;for(p=0;pi;o-=1)r-=n.child(o).nodeSize,e.delete(r-1,r+1);var s=e.doc.resolve(t.start),a=s.nodeAfter,l=0===t.startIndex,c=t.endIndex===n.childCount,u=s.node(-1),p=s.index(-1),h=u.canReplace(p+(l?0:1),p+1,null==a?void 0:a.content.append(c?d.empty:d.from(n)));if(a&&h){var f=s.pos,m=f+a.nodeSize;e.step(new wt(f-(l?1:0),m+(c?1:0),f+1,m-1,new v((l?d.empty:d.from(n.copy(d.empty))).append(c?d.empty:d.from(n.copy(d.empty))),l?0:1,c?0:1),l?0:1))}return e}(r,a);return n(l),!0}return!1}}function zh(){return function(){return function(e,t){var n=e.selection,r=e.schema,o=n.$from,i=n.$to;return!(!o.blockRange(i)||!Eh(o))&&function(e){return function(t,n){var r=t.tr,o=t.selection,i=o.$from,s=o.$to,a=i.blockRange(s,(function(t){var n=t.childCount,r=t.firstChild;return!!n&&r.type===e}));if(a&&a.startIndex>0){var l=a.parent,c=l.child(a.startIndex-1);if(c.type!==e)return!1;var u=c.lastChild&&c.lastChild.type===l.type,p=u?d.from(e.create()):null,h=new v(d.from(e.create(null,d.from(l.type.create(null,p)))),u?3:1,0),f=a.start,m=a.end;return r.step(new wt(f-(u?3:1),m,f,m,h,1,!0)),n(r),!0}return!1}}(r.nodes.listItem)(e,t)}}}function qh(){return{indent:zh(),outdent:function(){return function(e,t){var n=e.selection,r=e.schema,o=n.$from,i=n.$to;return!(!o.blockRange(i)||!Eh(o))&&Hh(r.nodes.listItem)(e,t)}}}}var Vh=new Map,jh=function(){function e(e,t,n,r){this.table=e,this.tableRows=t,this.tableStartPos=n,this.rowInfo=r}return e.create=function(t){var n=Sh(t,(function(e){return"table"===e.type.name}));if(n){var r=n.node,o=n.depth,i=n.offset,s=Vh.get(r);if((null==s?void 0:s.tableStartPos)===i+1)return s;var a=[],l=t.start(o),c=r.child(0),u=r.child(1),d=$h(c,l),p=$h(u,l+c.nodeSize);c.forEach((function(e){return a.push(e)})),u.forEach((function(e){return a.push(e)}));var h=new e(r,a,l,d.concat(p));return Vh.set(r,h),h}return null},Object.defineProperty(e.prototype,"totalRowCount",{get:function(){return this.rowInfo.length},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"totalColumnCount",{get:function(){return this.rowInfo[0].length},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"tableStartOffset",{get:function(){return this.tableStartPos},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"tableEndOffset",{get:function(){return this.tableStartPos+this.table.nodeSize-1},enumerable:!1,configurable:!0}),e.prototype.getCellInfo=function(e,t){return this.rowInfo[e][t]},e.prototype.posAt=function(e,t){for(var n=0,r=this.tableStartPos;;n+=1){var o=r+this.tableRows[n].nodeSize;if(n===e){for(var i=t;ie.pos)return[t,r];return[0,0]},e.prototype.getRectOffsets=function(e,t){var n,r,o;void 0===t&&(t=e),e.pos>t.pos&&(e=(n=[t,e])[0],t=n[1]);var i=this.getCellIndex(e),s=i[0],a=i[1],l=this.getCellIndex(t),c=l[0],u=l[1];return s=(r=st(s,c))[0],c=r[1],a=(o=st(a,u))[0],u=o[1],this.getSpannedOffsets({startRowIdx:s,startColIdx:a,endRowIdx:c,endColIdx:u})},e.prototype.getSpannedOffsets=function(e){return e},e}(),$h=function(e,t){var n=[];return e.forEach((function(e,r){var o={rowspanMap:{},colspanMap:{},length:0};e.forEach((function(e,n){for(var i=e.nodeSize,s=0;o[s];)s+=1;o[s]={offset:t+r+n+2,nodeSize:i},o.length+=1})),n.push(o)})),n};function _h(e,t){return it(jh.prototype,e),$h=t,jh}var Uh=function(e){function n(t,n){void 0===n&&(n=t);var r=this,o=t.node(0),i=jh.create(t),s=i.getRectOffsets(t,n),a=function(e,t,n){for(var r=n.startRowIdx,o=n.startColIdx,i=n.endRowIdx,s=n.endColIdx,a=[],l=r;l<=i;l+=1)for(var c=o;c<=s;c+=1){var u=t.getCellInfo(l,c),d=u.offset,p=u.nodeSize;a.push(new jt(e.resolve(d+1),e.resolve(d+p-1)))}return a}(o,i,s);return(r=e.call(this,a[0].$from,a[0].$to,a)||this).startCell=t,r.endCell=n,r.offsetMap=i,r.isCellSelection=!0,r.visible=!1,r}return t(n,e),n.prototype.map=function(e,t){var r=this.startCell.pos,o=this.endCell.pos,i=e.resolve(t.map(r)),s=e.resolve(t.map(o)),a=jh.create(i);if(this.offsetMap.totalColumnCount>a.totalColumnCount||this.offsetMap.totalRowCount>a.totalRowCount){var l={tableBody:1,tableRow:2,tableCell:3,paragraph:4}[s.parent.type.name],c=s.end(s.depth-l),u=Math.min(c-4,s.pos);return Ut.create(e,u)}return new n(i,s)},n.prototype.eq=function(e){return e instanceof n&&e.startCell.pos===this.startCell.pos&&e.endCell.pos===this.endCell.pos},n.prototype.content=function(){for(var e=this.startCell.node(-2),t=this.startCell.start(-2),n=e.child(1).firstChild,r=e.child(0).type.create(),o=e.child(1).type.create(),i=jh.create(this.startCell),s=i.getRectOffsets(this.startCell,this.endCell),a=s.startRowIdx,l=s.startColIdx,c=s.endRowIdx,u=s.endColIdx,p=!1,h=a;h<=c;h+=1){for(var f=[],m=l;m<=u;m+=1){var g=i.getCellInfo(h,m).offset,y=e.nodeAt(g-t);y&&(p="tableHeadCell"===y.type.name,i.extendedRowspan(h,m)||i.extendedColspan(h,m)?f.push(y.type.create({extended:!0})):f.push(y.copy(y.content)))}var b=n.copy(d.from(f)),w=p?r:o;w.content=w.content.append(d.from(b))}return new v(function(e,t){var n=[];return e.childCount&&n.push(e),t.childCount&&n.push(t),d.from(n)}(r,o),1,1)},n.prototype.toJSON=function(){return JSON.stringify(this)},n}(Vt),Wh=Uh;function Jh(e,t,n,r){for(var o=n.nodes,i=o.tableRow,s=o.tableBodyCell,a=o.paragraph,l=[],c=0;c0&&o>0||"table"===(null===(t=n.firstChild)||void 0===t?void 0:t.type.name));)r-=1,o-=1,n=n.firstChild.content;if("tableHead"===n.firstChild.type.name||"tableBody"===n.firstChild.type.name)return n}return null}function Yh(e){var t=e.startRowIdx,n=e.startColIdx;return{rowCount:e.endRowIdx-t+1,columnCount:e.endColIdx-n+1}}function ef(e,t){return o(o({},e.attrs),t)}var tf=new un("cellSelection"),nf=function(){function e(e){this.view=e,this.handlers={mousedown:this.handleMousedown.bind(this),mousemove:this.handleMousemove.bind(this),mouseup:this.handleMouseup.bind(this)},this.startCellPos=null,this.init()}return e.prototype.init=function(){this.view.dom.addEventListener("mousedown",this.handlers.mousedown)},e.prototype.handleMousedown=function(e){var t=Kh(e.target,this.view.dom);if(2!==e.button){if(t){var n=this.getCellPos(e);n&&(this.startCellPos=n),this.bindEvent()}}else e.preventDefault()},e.prototype.handleMousemove=function(e){var t,n=tf.getState(this.view.state),r=this.getCellPos(e),o=this.startCellPos;n?t=this.view.state.doc.resolve(n):o!==r&&(t=o),t&&o&&r&&this.setCellSelection(o,r)},e.prototype.handleMouseup=function(){this.startCellPos=null,this.unbindEvent(),null!==tf.getState(this.view.state)&&this.view.dispatch(this.view.state.tr.setMeta(tf,-1))},e.prototype.bindEvent=function(){var e=this.view.dom;e.addEventListener("mousemove",this.handlers.mousemove),e.addEventListener("mouseup",this.handlers.mouseup)},e.prototype.unbindEvent=function(){var e=this.view.dom;e.removeEventListener("mousemove",this.handlers.mousemove),e.removeEventListener("mouseup",this.handlers.mouseup)},e.prototype.getCellPos=function(e){var t=e.clientX,n=e.clientY,r=this.view.posAtCoords({left:t,top:n});if(r){var o=this.view.state.doc,i=o.resolve(r.pos),s=Zh(i);if(s){var a=i.before(s.depth);return o.resolve(a)}}return null},e.prototype.setCellSelection=function(e,t){var n=this.view.state,r=n.selection,o=n.tr,i=null===tf.getState(this.view.state),s=new Wh(e,t);if(i||!r.eq(s)){var a=o.setSelection(s);i&&a.setMeta(tf,t.pos),this.view.dispatch(a)}},e.prototype.destroy=function(){this.view.dom.removeEventListener("mousedown",this.handlers.mousedown)},e}(),rf=nf,of=xa("cell-selected");function sf(e){var t=e.selection,n=e.doc;if(t instanceof Wh){var r=[];return t.ranges.forEach((function(e){var t=e.$from,n=e.$to;r.push(Vo.node(t.pos-1,n.pos+1,{class:of}))})),_o.create(n,r)}return null}var af=n(928),lf=n.n(af),cf=function(){function e(){this.keys=[],this.values=[]}return e.prototype.getKeyIndex=function(e){return lf()(e,this.keys)},e.prototype.get=function(e){return this.values[this.getKeyIndex(e)]},e.prototype.set=function(e,t){var n=this.getKeyIndex(e);return n>-1?this.values[n]=t:(this.keys.push(e),this.values.push(t)),this},e.prototype.has=function(e){return this.getKeyIndex(e)>-1},e.prototype.delete=function(e){var t=this.getKeyIndex(e);return t>-1&&(this.keys.splice(t,1),this.values.splice(t,1),!0)},e.prototype.forEach=function(e,t){var n=this;void 0===t&&(t=this),this.values.forEach((function(r,o){r&&n.keys[o]&&e.call(t,r,n.keys[o],n)}))},e.prototype.clear=function(){this.keys=[],this.values=[]},e}(),uf=cf,df="en-US",pf=function(){function e(){this.code=df,this.langs=new uf}return e.prototype.setCode=function(e){this.code=e||df},e.prototype.setLanguage=function(e,t){var n=this;(e=[].concat(e)).forEach((function(e){if(n.langs.has(e)){var r=n.langs.get(e);n.langs.set(e,ve()(r,t))}else n.langs.set(e,t)}))},e.prototype.get=function(e,t){t||(t=this.code);var n=this.langs.get(t);n||(n=this.langs.get(df));var r=n[e];if(!r)throw new Error('There is no text key "'+e+'" in '+t);return r},e}(),hf=new pf,ff=[[{action:"Add row to up",command:"addRowToUp",disableInThead:!0,className:"add-row-up"},{action:"Add row to down",command:"addRowToDown",disableInThead:!0,className:"add-row-down"},{action:"Remove row",command:"removeRow",disableInThead:!0,className:"remove-row"}],[{action:"Add column to left",command:"addColumnToLeft",className:"add-column-left"},{action:"Add column to right",command:"addColumnToRight",className:"add-column-right"},{action:"Remove column",command:"removeColumn",className:"remove-column"}],[{action:"Align column to left",command:"alignColumn",payload:{align:"left"},className:"align-column-left"},{action:"Align column to center",command:"alignColumn",payload:{align:"center"},className:"align-column-center"},{action:"Align column to right",command:"alignColumn",payload:{align:"right"},className:"align-column-right"}],[{action:"Remove table",command:"removeTable",className:"remove-table"}]];function mf(e,t){return ff.map((function(n){return n.map((function(n){var r=n.action,o=n.command,i=n.payload,s=n.disableInThead,a=n.className;return{label:hf.get(r),onClick:function(){e.emit("command",o,i)},disabled:t&&!!s,className:a}}))})).concat()}function gf(e){return new an({props:{handleDOMEvents:{contextmenu:function(t,n){var r=Kh(n.target,t.dom);if(r){n.preventDefault();var o=n,i=o.clientX,s=o.clientY,a=t.dom.parentNode.getBoundingClientRect(),l=a.left,c=a.top,u="TH"===r.nodeName;return e.emit("contextmenu",{pos:{left:i-l+10+"px",top:s-c+30+"px"},menuGroups:mf(e,u),tableCell:r}),!0}return!1}}}})}var vf=["image","link","customBlock","frontMatter"],yf=["strong","strike","emph","code"],bf=["bulletList","orderedList","taskList"];function wf(e,t,n){var r=e.$from,o=e.$to,i=e.from,s=e.to,a={indent:{active:!1,disabled:!0},outdent:{active:!1,disabled:!0}};return t.nodesBetween(i,s,(function(e,t,i){var s=function(e,t){var n=e.type.name;return"listItem"===n?e.attrs.task?"taskList":t.type.name:-1!==n.indexOf("table")?"table":n}(e,i);Ue(vf,s)||(Ue(bf,s)?(!function(e,t){t[e]={active:!0},bf.filter((function(t){return t!==e})).forEach((function(e){t[e]&&delete t[e]}))}(s,a),a.indent.disabled=!1,a.outdent.disabled=!1):"paragraph"===s||"text"===s?function(e,t,n,r){yf.forEach((function(o){var i=n.marks[o],s=e.marksAcross(t)||[];i.isInSet(s)&&(r[o]={active:!0})}))}(r,o,n,a):a[s]={active:!0})})),a}function kf(e){return new an({view:function(){return{update:function(t){var n=t.state,r=n.selection,o=n.doc,i=n.schema;e.emit("changeToolbarState",{toolbarState:wf(r,o,i)})}}}})}var xf=function(){function e(e,t,n,r){var o=this;this.openEditor=function(){if(o.innerEditorView)throw new Error("The editor is already opened.");o.dom.draggable=!1,o.wrapper.style.display="none",o.innerViewContainer.style.display="block",o.innerEditorView=new ui(o.innerViewContainer,{state:on.create({doc:o.node,plugins:[Ai({"Mod-z":function(){return Ms(o.innerEditorView.state,o.innerEditorView.dispatch)},"Shift-Mod-z":function(){return Ss(o.innerEditorView.state,o.innerEditorView.dispatch)},Tab:function(e,t){return t(e.tr.insertText("\t")),!0},Enter:Hi,Escape:function(){return o.cancelEditing(),!0},"Ctrl-Enter":function(){return o.saveAndFinishEditing(),!0}}),Ts()]}),dispatchTransaction:function(e){return o.dispatchInner(e)},handleDOMEvents:{mousedown:function(){return o.editorView.hasFocus()&&o.innerEditorView.focus(),!0},blur:function(){return o.saveAndFinishEditing(),!0}}}),o.innerEditorView.focus()},this.node=e,this.editorView=t,this.getPos=n,this.toDOMAdaptor=r,this.innerEditorView=null,this.canceled=!1,this.dom=document.createElement("div"),this.dom.className=xa("custom-block"),this.wrapper=document.createElement("div"),this.wrapper.className=xa("custom-block-view"),this.createInnerViewContainer(),this.renderCustomBlock(),this.dom.appendChild(this.innerViewContainer),this.dom.appendChild(this.wrapper)}return e.prototype.renderToolArea=function(){var e=this,t=document.createElement("div"),n=document.createElement("span"),r=document.createElement("button");t.className="tool",n.textContent=this.node.attrs.info,n.className="info",r.type="button",r.addEventListener("click",(function(){return e.openEditor()})),t.appendChild(n),t.appendChild(r),this.wrapper.appendChild(t)},e.prototype.renderCustomBlock=function(){var e=this.toDOMAdaptor.getToDOMNode(this.node.attrs.info);if(e){for(var t=e(this.node);this.wrapper.hasChildNodes();)this.wrapper.removeChild(this.wrapper.lastChild);t&&this.wrapper.appendChild(t),this.renderToolArea()}},e.prototype.createInnerViewContainer=function(){this.innerViewContainer=document.createElement("div"),this.innerViewContainer.className=xa("custom-block-editor"),this.innerViewContainer.style.display="none"},e.prototype.closeEditor=function(){this.innerEditorView&&(this.innerEditorView.destroy(),this.innerEditorView=null,this.innerViewContainer.style.display="none"),this.wrapper.style.display="block"},e.prototype.saveAndFinishEditing=function(){var e=this.editorView.state.selection.to,t=this.editorView.state;this.editorView.dispatch(t.tr.setSelection(Os(t.tr,e))),this.editorView.focus(),this.renderCustomBlock(),this.closeEditor()},e.prototype.cancelEditing=function(){var e=function(e){let t=xs.getState(e);return t?t.done.eventCount:0}(this.innerEditorView.state);for(this.canceled=!0;e--;)Ms(this.innerEditorView.state,this.innerEditorView.dispatch),Ms(this.editorView.state,this.editorView.dispatch);this.canceled=!1;var t=this.editorView.state.selection.to,n=this.editorView.state;this.editorView.dispatch(n.tr.setSelection(Ut.create(n.doc,t))),this.editorView.focus(),this.closeEditor()},e.prototype.dispatchInner=function(e){var t=this.innerEditorView.state.applyTransaction(e),n=t.state,r=t.transactions;if(this.innerEditorView.updateState(n),!this.canceled&&Ga()(this.getPos)){for(var o=this.editorView.state.tr,i=dt.offset(this.getPos()+1),s=0;s
":"")+t.innerHTML}var Bf="\x3c!--StartFragment--\x3e";function Ff(e){return function(e){return Ef.test(e)}(e=function(e){return/<\/td>((?!<\/tr>)[\s\S])*$/i.test(e)&&(e=""+e+" "),/<\/tr>((?!<\/table>)[\s\S])*$/i.test(e)&&(e=""),e}(e=function(e){var t=e.indexOf(Bf),n=e.lastIndexOf("\x3c!--EndFragment--\x3e");return t>-1&&n>-1&&(e=e.slice(t+Bf.length,n)),e.replace(/ ]*>/g,ba)}(e)))&&(e=Pf(e)),e}function Hf(e,t,n){for(var r=[],o=e.childCount,i=0;it.childCount?e:t})).childCount}(e);if(n&&r)return t.nodes.table.create(null,[$f(e,o,t)]);var i=e[0],s=e.slice(1),a=[jf(i,0,t)];return s.length&&a.push($f(s,o,t)),t.nodes.table.create(null,a)}(i,t,s,n);r.push(a)}}else r.push(e)})),new v(d.from(r),i,s)}function Uf(e){return 4*e}function Wf(e,t){var n=e.state,r=n.selection,o=n.schema,i=n.tr,s=Xh(r),a=s.anchor,l=s.head;if(a&&l){var c=Qh(t);if(!c)return!1;var u=jh.create(a),d=u.getRectOffsets(a,l),p=function(e,t,n){var r=[],o=Vf(e),i=(o[0].childCount,o.length),s=0===t.startRowIdx,a=o.slice(0,i);if(s){var l=a.shift();if(l){var c=zf(l,0,n).content;r.push(c)}}return a.forEach((function(e){if(!e.attrs.dummyRowForPasting){var t=qf(e,0,n).content;r.push(t)}})),r}(c,d,o),h=function(e,t,n){for(var r=t.startRowIdx,o=t.startColIdx,i=n.length,s=0,a=function(e){var t=n[e].childCount;n[e].forEach((function(e){var n=e.attrs.colspan;n>1&&(t+=n-1)})),s=Math.max(s,t)},l=0;l=i&&h<=a-c){var b=n.getCellInfo(h,l-u),w=e.mapping.map(b.offset),k=v+Uf(u);o[p]={rowIdx:h,startColIdx:s,endColIdx:l,dummyOffsets:[w,k]},p+=1}}}(i,o,u,h,f),h.addedRowCount&&function(e,t,n,r,o){var i=r.addedRowCount,s=r.addedColumnCount,a=r.startColIdx,l=r.endColIdx,c=e.mapping.maps.length,u=n.tableEndOffset-2,d=Jh(i,n.totalColumnCount+s,t),p=u;e.insert(e.mapping.slice(c).map(p),d);for(var h=0;h=p[0]&&o<=i&&!f){var m=Ds(s,h,l);if(m)return r(m),!0}}return!1}},n.prototype.keymaps=function(){var e=this.commands()();return{"Shift-Mod-p":e,"Shift-Mod-P":e,ArrowUp:this.moveCursor("up"),ArrowDown:this.moveCursor("down")}},n}(Va),em=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"bulletList"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{content:"listItem+",group:"block",attrs:o({rawHTML:{default:null}},{htmlAttrs:{default:null},classNames:{default:null}}),parseDOM:[Dh("ul")],toDOM:function(e){return["ul",Ih(e.attrs),0]}}},enumerable:!1,configurable:!0}),n.prototype.changeList=function(){return function(e,t){return Bh(e.schema.nodes.bulletList)(e,t)}},n.prototype.commands=function(){return{bulletList:this.changeList,taskList:Fh}},n.prototype.keymaps=function(){var e=this.changeList(),t=qh(),n=t.indent,r=t.outdent;return{"Mod-u":e,"Mod-U":e,Tab:n(),"Shift-Tab":r()}},n}(Va),tm=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"orderedList"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{content:"listItem+",group:"block",attrs:o({order:{default:1},rawHTML:{default:null}},{htmlAttrs:{default:null},classNames:{default:null}}),parseDOM:[{tag:"ol",getAttrs:function(e){var t=e.getAttribute("start"),n=e.getAttribute("data-raw-html");return o({order:e.hasAttribute("start")?Number(t):1},n&&{rawHTML:n})}}],toDOM:function(e){var t=e.attrs;return[t.rawHTML||"ol",o({start:1===t.order?null:t.order},Ih(t)),0]}}},enumerable:!1,configurable:!0}),n.prototype.commands=function(){return function(){return function(e,t){return Bh(e.schema.nodes.orderedList)(e,t)}}},n.prototype.keymaps=function(){var e=this.commands()(),t=qh(),n=t.indent,r=t.outdent;return{"Mod-o":e,"Mod-O":e,Tab:n(),"Shift-Tab":r()}},n}(Va),nm=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"listItem"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{content:"paragraph block*",selectable:!1,attrs:{task:{default:!1},checked:{default:!1},rawHTML:{default:null}},defining:!0,parseDOM:[{tag:"li",getAttrs:function(e){var t=e.getAttribute("data-raw-html");return o({task:e.hasAttribute("data-task"),checked:e.hasAttribute("data-task-checked")},t&&{rawHTML:t})}}],toDOM:function(e){var t=e.attrs,n=t.task,r=t.checked;if(!n)return[t.rawHTML||"li",0];var i=["task-list-item"];return r&&i.push("checked"),[t.rawHTML||"li",o({class:i.join(" "),"data-task":n},r&&{"data-task-checked":r}),0]}}},enumerable:!1,configurable:!0}),n.prototype.liftToPrevListItem=function(){return function(e,t){var n=e.selection,r=e.tr,o=e.schema,i=n.$from,s=n.empty,a=o.nodes.listItem,l=i.parent,c=i.node(-1);if(s&&!l.childCount&&c.type===a){if(i.index(-2)>=1)return r.delete(i.start(-1)-1,i.end(-1)),t(r),!0;if(i.node(-3).type===a)return r.delete(i.start(-2)-1,i.end(-1)),t(r),!0}return!1}},n.prototype.keymaps=function(){return{Backspace:this.liftToPrevListItem(),Enter:function(e,t){return function(e){return function(t,n){var r=t.tr,o=t.selection,i=o.$from,s=o.$to;if(i.depth<2||!i.sameParent(s))return!1;var a=i.node(-1);if(a.type!==e)return!1;if(0===i.parent.content.size&&i.node(-1).childCount===i.indexAfter(-1)){if(2===i.depth||i.node(-3).type!==e||i.index(-2)!==i.node(-2).childCount-1)return!1;for(var l=i.index(-1)>0,c=d.empty,u=i.depth-(l?1:2);u>=i.depth-3;u-=1)c=d.from(i.node(u).copy(c));return c=c.append(d.from(e.createAndFill())),r.replace(l?i.before():i.before(-1),i.after(-3),new v(c,l?3:2,2)),r.setSelection(Vt.near(r.doc.resolve(i.pos+(l?3:2)))),n(r),!0}var p=s.pos===i.end()?a.contentMatchAt(0).defaultType:null,h=p&&[null,{type:p}];return r.delete(i.pos,s.pos),!!Mt(r.doc,i.pos,2,h)&&(r.split(i.pos,2,h),n(r),!0)}}(e.schema.nodes.listItem)(e,t)}}},n}(Va),rm=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"blockQuote"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{attrs:o({rawHTML:{default:null}},{htmlAttrs:{default:null},classNames:{default:null}}),content:"block+",group:"block",parseDOM:[Dh("blockquote")],toDOM:function(e){return["blockquote",Ih(e.attrs),0]}}},enumerable:!1,configurable:!0}),n.prototype.commands=function(){return function(){return function(e,t){return Ji(e.schema.nodes.blockQuote)(e,t)}}},n.prototype.keymaps=function(){var e=this.commands()();return{"Alt-q":e,"Alt-Q":e}},n}(Va),om={left:function(e,t){var n=e[0],r=e[1],o=t.totalColumnCount,i=0===r;if(0!==n||!i){r-=1,i&&(n-=1,r=o-1);var s=t.getCellInfo(n,r),a=s.offset,l=s.nodeSize;return a+l-2}return null},right:function(e,t){var n=e[0],r=e[1],o=t.totalRowCount,i=t.totalColumnCount,s=r===i-1;if(n!==o-1||!s){var a=r+1,l=t.getColspanStartInfo(n,r);return(null==l?void 0:l.count)>1&&(a+=l.count-1),(s||a===i)&&(n+=1,a=0),t.getCellInfo(n,a).offset+2}return null},up:function(e,t){var n=e[0],r=e[1];if(n>0){var o=t.getCellInfo(n-1,r),i=o.offset,s=o.nodeSize;return i+s-2}return null},down:function(e,t){var n=e[0],r=e[1],o=t.totalRowCount;if(n1&&(i+=s.count-1),t.getCellInfo(i,r).offset+2}return null}};function im(e,t,n,r){var o=t[0],i=t[1],s=o+3,a=i>=s,l=a?o+1:i,c=function(e,t,n,r,o){var i=t[0],s=t[1],a=t[2];if(e===Jf.LEFT||e===Jf.UP){if(o&&!function(e,t,n){var r=n[0],o=n[1],i=t.resolve(e.before(r-1));return o===r&&!i.nodeBefore}(n,r,[i,s]))return!1;var l=n.before(a);if(r.resolve(l).nodeBefore)return!1}return!0}(e,[i,s,l],n,r,a),u=function(e,t,n,r,o){if(e===Jf.RIGHT||e===Jf.DOWN){if(o&&!function(e){for(var t,n,r=e.depth;r&&"tableBodyCell"!==(n=e.node(r)).type.name;){if("listItem"===n.type.name){var o=e.node(r-1).lastChild===n,i="paragraph"!==(null===(t=n.lastChild)||void 0===t?void 0:t.type.name);return!!o&&!i}r-=1}return!1}(n))return!1;var i=n.after(t);if(r.resolve(i).nodeAfter)return!1}return!0}(e,l,n,r,a);return c&&u}function sm(e,t,n){var r=n[0],o=n[1],i=t.getRowspanStartInfo(r,o),s=e===Jf.UP&&0===r,a=e===Jf.DOWN&&((null==i?void 0:i.count)>1?r+i.count-1:r)===t.totalRowCount-1;return s||a}function am(e,t,n,r){void 0===r&&(r=!1);var o=e.doc.resolve(t.tableEndOffset);return r||!o.nodeAfter?Ds(e,o,n):e.setSelection(Vt.near(o,1))}function lm(e,t,n,r){var o=(0,om[e])(n,r);if(o){var i=e===Jf.RIGHT||e===Jf.DOWN?1:-1;return t.setSelection(Vt.near(t.doc.resolve(o),i))}return null}function cm(e,t,n){var r=e.getCellInfo(t,0).offset,o=e.getCellInfo(t,n-1);return{from:r,to:o.offset+o.nodeSize}}!function(e){e.LEFT="left",e.RIGHT="right",e.UP="up",e.DOWN="down"}(Jf||(Jf={}));var um=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),Object.defineProperty(n.prototype,"name",{get:function(){return"table"},enumerable:!1,configurable:!0}),Object.defineProperty(n.prototype,"schema",{get:function(){return{content:"tableHead{1} tableBody{1}",group:"block",attrs:o({rawHTML:{default:null}},{htmlAttrs:{default:null},classNames:{default:null}}),parseDOM:[Dh("table")],toDOM:function(e){return["table",Ih(e.attrs),0]}}},enumerable:!1,configurable:!0}),n.prototype.addTable=function(){return function(e){return void 0===e&&(e={rowCount:2,columnCount:1,data:[]}),function(t,n){var r=e.rowCount,o=e.columnCount,i=e.data,s=t.schema,a=t.selection,l=t.tr,c=a.from,u=a.to,d=a.$from;if(c===u&&!Nh(d)){var p=s.nodes,h=p.tableHead,f=p.tableBody,m=null==i?void 0:i.slice(0,o),g=null==i?void 0:i.slice(o,i.length),v=function(e,t,n){for(var r=t.nodes,o=r.tableRow,i=r.tableHeadCell,s=r.paragraph,a=[],l=0;l=d;m-=1){var g=a.getCellInfo(f,m),v=g.offset,y=g.nodeSize,b=r.mapping.slice(h).map(v),w=b+y;r.delete(b,w)}return t(r),!0}return!1}}},n.prototype.addRow=function(e){return function(){return function(t,n){var r=t.selection,o=t.schema,i=t.tr,s=Xh(r),a=s.anchor,l=s.head;if(a&&l){var c=jh.create(a),u=c.totalColumnCount,d=c.getRectOffsets(a,l),p=Yh(d).rowCount,h=function(e,t,n){var r,o,i;return e===Jf.UP?(r=n.startRowIdx,o=0,i=-1):(r=n.endRowIdx,o=t.totalColumnCount-1,i=t.getCellInfo(r,o).nodeSize+1),{targetRowIdx:r,insertColIdx:o,nodeSize:i}}(e,c,d),f=h.targetRowIdx,m=h.insertColIdx,g=h.nodeSize;if(!(0===f)){for(var v=[],y=i.mapping.map(c.posAt(f,m))+g,b=[],w=0;w=p;f-=1){var m=cm(a,f,c),g=m.from,v=m.to;r.delete(g-1,v+1)}return t(r),!0}return!1}}},n.prototype.alignColumn=function(){return function(e){return void 0===e&&(e={align:"center"}),function(t,n){var r=e.align,o=t.selection,i=t.tr,s=Xh(o),a=s.anchor,l=s.head;if(a&&l){for(var c=jh.create(a),u=c.totalRowCount,d=c.getRectOffsets(a,l),p=d.startColIdx,h=d.endColIdx,f=0;f=s&&function(e){return!e.length||Ue(e,u)}(i)&&(l=(a+=t)+r)}))}return{range:[a,l],type:c}},n}(Wa),Nm=Em,Om=n(404),Dm=n.n(Om),Am=["afterPreviewRender","updatePreview","changeMode","needChangeMode","command","changePreviewStyle","changePreviewTabPreview","changePreviewTabWrite","scroll","contextmenu","show","hide","changeLanguage","changeToolbarState","toggleScrollSync","mixinTableOffsetMapPrototype","setFocusedNode","removePopupWidget","query","openPopup","closePopup","addImageBlobHook","beforePreviewRender","beforeConvertWysiwygToMarkdown","load","loadUI","change","caretChange","destroy","focus","blur","keydown","keyup"],Lm=function(){function e(){var e=this;this.events=new uf,this.eventTypes=Am.reduce((function(e,t){return o(o({},e),{type:t})}),{}),this.hold=!1,Am.forEach((function(t){e.addEventType(t)}))}return e.prototype.listen=function(e,t){var n=this.getTypeInfo(e),r=this.events.get(n.type)||[];if(!this.hasEventType(n.type))throw new Error("There is no event type "+n.type);n.namespace&&(t.namespace=n.namespace),r.push(t),this.events.set(n.type,r)},e.prototype.emit=function(e){for(var t=[],n=1;n=0&&n.splice(r,1)}},e.prototype.removeEventHandlerWithTypeInfo=function(e,t){var n=[],r=this.events.get(e);r&&(r.map((function(e){return e.namespace!==t&&n.push(e),null})),this.events.set(e,n))},e.prototype.getEvents=function(){return this.events},e.prototype.holdEventInvoke=function(e){this.hold=!0,e(),this.hold=!1},e}(),Im=Lm,Rm=function(){function e(e,t,n,r){this.eventEmitter=e,this.mdCommands=t,this.wwCommands=n,this.getEditorType=r,this.initEvent()}return e.prototype.initEvent=function(){var e=this;this.eventEmitter.listen("command",(function(t,n){e.exec(t,n)}))},e.prototype.addCommand=function(e,t,n){"markdown"===e?this.mdCommands[t]=n:this.wwCommands[t]=n},e.prototype.deleteCommand=function(e,t){"markdown"===e?delete this.mdCommands[t]:delete this.wwCommands[t]},e.prototype.exec=function(e,t){"markdown"===this.getEditorType()?this.mdCommands[e](t):this.wwCommands[e](t)},e}(),Pm=Rm;function Bm(e){return"\n"===e[e.length-1]?e.slice(0,e.length-1):e}function Fm(e,t){var n=e.schema,r=t.literal.match(ga);if(r){var o=r[1],i=r[3],s=(o||i).toLowerCase();return"htmlInline"===t.type&&!(!n.marks[s]&&!n.nodes[s])}return!1}function Hm(e){return Ue(["text","strong","emph","strike","image","link","code"],e.type)}function zm(e){return"softbreak"===(null==e?void 0:e.type)}function qm(e){var t=e.type,n=e.literal,r="htmlInline"===t&&n.match(ga);if(r){var o=r[1],i=r[3],s=o||i;if(s)return Ue(["ul","ol","li"],s.toLowerCase())}return!1}function Vm(e){for(var t=[],n=1;n=0;r-=1){var o=this.stack[r];if(!(null===(n=o.attrs)||void 0===n?void 0:n.rawHTML))break;o.content.length?this.closeNode():this.stack.pop()}},e.prototype.convert=function(e,t){for(var n=e.walker(),r=n.next(),o=function(){var e=r.node,o=r.entering,s=i.convertors[e.type],a=!1;if(s){var l={entering:o,leaf:!Ws(e),getChildrenText:Js,options:{gfm:!0,nodeId:!1,tagFilter:!1,softbreak:"\n"},skipChildren:function(){a=!0}};if(i.closeUnmatchedHTMLInline(e,o),s(i,e,l),(null==t?void 0:t.node)===e){var c=i.stack.reduce((function(e,t){return e+t.content.reduce((function(e,t){return e+t.nodeSize}),0)}),0)+1;t.setMappedPos(c)}}a&&(n.resumeAt(e,!1),n.next()),r=n.next()},i=this;r;)o()},e.prototype.convertNode=function(e,t){return this.convert(e,t),this.stack.length?this.closeNode():null},e}(),Gm=Jm;var Km={text:function(e,t){var n,r=t.node,o=null!==(n=r.text)&&void 0!==n?n:"";(r.marks||[]).some((function(e){return"link"===e.type.name}))?e.text(Ze(o),!1):e.text(o)},paragraph:function(e,t){var n=t.node,r=t.parent,o=t.index,i=void 0===o?0:o;if(e.stopNewline)e.convertInline(n);else{var s=0===i,a=!s&&r.child(i-1),l=a&&0===a.childCount,c=i\n");else if(!d||l||s)e.convertInline(n),u?e.write("\n"):e.closeBlock(n);else{if("listItem"===(null==r?void 0:r.type.name)){var p=e.getDelim();e.setDelim(""),e.write(" "),e.setDelim(p)}e.write("\n")}}},heading:function(e,t,n){var r=t.node,o=n.delim;"atx"===r.attrs.headingType?(e.write(o+" "),e.convertInline(r),e.closeBlock(r)):(e.convertInline(r),e.ensureNewLine(),e.write(o),e.closeBlock(r))},codeBlock:function(e,t,n){var r=t.node,o=n.delim,i=n.text,s=o,a=s[0],l=s[1];e.write(a),e.ensureNewLine(),e.text(i,!1),e.ensureNewLine(),e.write(l),e.closeBlock(r)},blockQuote:function(e,t,n){var r=t.node,o=t.parent,i=n.delim;(null==o?void 0:o.type.name)===r.type.name&&e.flushClose(1),e.wrapBlock(i,null,r,(function(){return e.convertNode(r)}))},bulletList:function(e,t,n){var r=t.node,o=n.delim;e.convertList(r,Ke(" ",4),(function(){return o+" "}))},orderedList:function(e,t){var n=t.node,r=n.attrs.order||1;e.convertList(n,Ke(" ",4),(function(e){return String(r+e)+". "}))},listItem:function(e,t){var n=t.node,r=n.attrs,o=r.task,i=r.checked;o&&e.write("["+(i?"x":" ")+"] "),e.convertNode(n)},image:function(e,t,n){var r=n.attrs;e.write("+")")},thematicBreak:function(e,t,n){var r=t.node,o=n.delim;e.write(o),e.closeBlock(r)},table:function(e,t){var n=t.node;e.convertNode(n),e.closeBlock(n)},tableHead:function(e,t,n){var r=t.node,o=n.delim,i=r.firstChild;e.convertNode(r);var s=null!=o?o:"";!o&&i&&i.forEach((function(e){var t=function(e,t){var n=e.length,r="",o="";return"left"===t?(r=":",n-=1):"right"===t?(o=":",n-=1):"center"===t&&(r=":",o=":",n-=2),""+r+Ke("-",Math.max(n,3))+o}(e.textContent,e.attrs.align);s+="| "+t+" "})),e.write(s+"|"),e.ensureNewLine()},tableBody:function(e,t){var n=t.node;e.convertNode(n)},tableRow:function(e,t){var n=t.node;e.convertNode(n),e.write("|"),e.ensureNewLine()},tableHeadCell:function(e,t,n){var r=t.node,o=n.delim,i=void 0===o?"| ":o;e.write(i),e.convertTableCell(r),e.write(" ")},tableBodyCell:function(e,t,n){var r=t.node,o=n.delim,i=void 0===o?"| ":o;e.write(i),e.convertTableCell(r),e.write(" ")},customBlock:function(e,t,n){var r=t.node,o=n.delim,i=n.text,s=o,a=s[0],l=s[1];e.write(a),e.ensureNewLine(),e.text(i,!1),e.ensureNewLine(),e.write(l),e.closeBlock(r)},frontMatter:function(e,t,n){var r=t.node,o=n.text;e.text(o,!1),e.closeBlock(r)},widget:function(e,t,n){var r=n.text;e.write(r)},html:function(e,t,n){var r=t.node,o=n.text;e.write(o),r.attrs.htmlBlock&&e.closeBlock(r)},htmlComment:function(e,t,n){var r=t.node,o=n.text;e.write(o),e.closeBlock(r)}};function Zm(e,t){var n=e.text,r=/`+/g,o=0;if(e.isText&&n)for(var i=r.exec(n);i;)o=Math.max(o,i[0].length),i=r.exec(n);for(var s=o>0&&t>0?" `":"`",a=0;a0&&t<0&&(s+=" "),s}function Xm(e){return e?["<"+e+">",""+e+">"]:null}function Qm(e){return e?"<"+e+">":null}function Ym(e){return e?""+e+">":null}var eg={heading:function(e){var t=e.node.attrs,n=t.level,r=Ke("#",n);return"setext"===t.headingType&&(r=1===n?"===":"---"),{delim:r,rawHTML:Xm(t.rawHTML)}},codeBlock:function(e){var t=e.node,n=t.attrs,r=t.textContent;return{delim:["```"+(n.language||""),"```"],rawHTML:Xm(n.rawHTML),text:r}},blockQuote:function(e){return{delim:"> ",rawHTML:Xm(e.node.attrs.rawHTML)}},bulletList:function(e,t){var n=e.node,r=t.inTable,o=n.attrs.rawHTML;return r&&(o=o||"ul"),{delim:"*",rawHTML:Xm(o)}},orderedList:function(e,t){var n=e.node,r=t.inTable,o=n.attrs.rawHTML;return r&&(o=o||"ol"),{rawHTML:Xm(o)}},listItem:function(e,t){var n=e.node,r=t.inTable,o=n.attrs,i=o.task,s=o.checked,a=n.attrs.rawHTML;return r&&(a=a||"li"),{rawHTML:a?["<"+a+(i?' class="task-list-item'+(s?" checked":"")+'"':"")+(i?" data-task"+(s?" data-task-checked":""):"")+">",""+a+">"]:null}},table:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},tableHead:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},tableBody:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},tableRow:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},tableHeadCell:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},tableBodyCell:function(e){return{rawHTML:Xm(e.node.attrs.rawHTML)}},image:function(e){var t=e.node.attrs,n=t.rawHTML,r=t.altText,o=t.imageUrl.replace(/&/g,"&"),i=r?' alt="'+_e(r)+'"':"";return{rawHTML:n?"<"+n+' src="'+_e(o)+'"'+i+">":null,attrs:{altText:Ze(r||""),imageUrl:o}}},thematicBreak:function(e){return{delim:"***",rawHTML:Qm(e.node.attrs.rawHTML)}},customBlock:function(e){var t=e.node,n=t.attrs,r=t.textContent;return{delim:["$$"+n.info,"$$"],text:r}},frontMatter:function(e){return{text:e.node.textContent}},widget:function(e){return{text:e.node.textContent}},strong:function(e,t){var n=e.node,r=t.entering,o=n.attrs.rawHTML;return{delim:"**",rawHTML:r?Qm(o):Ym(o)}},emph:function(e,t){var n=e.node,r=t.entering,o=n.attrs.rawHTML;return{delim:"*",rawHTML:r?Qm(o):Ym(o)}},strike:function(e,t){var n=e.node,r=t.entering,o=n.attrs.rawHTML;return{delim:"~~",rawHTML:r?Qm(o):Ym(o)}},link:function(e,t){var n,r,o=e.node,i=t.entering,s=o.attrs,a=s.title,l=s.rawHTML,c=s.linkUrl.replace(/&/g,"&"),u=a?' title="'+_e(a)+'"':"";return i?{delim:"[",rawHTML:l?"<"+l+' href="'+_e(c)+'"'+u+">":null}:{delim:"]("+c+(a?" "+(n=Ze(a),(r=-1===n.indexOf('"')?'""':-1===n.indexOf("'")?"''":"()")[0]+n+r[1]):"")+")",rawHTML:Ym(l)}},code:function(e,t){var n=e.node,r=e.parent,o=e.index,i=void 0===o?0:o,s=t.entering;return{delim:s?Zm(r.child(i),-1):Zm(r.child(i-1),1),rawHTML:s?Qm(n.attrs.rawHTML):Ym(n.attrs.rawHTML)}},htmlComment:function(e){return{text:e.node.textContent}},html:function(e,t){var n=e.node,r=t.entering,o=n.type.name,i=n.attrs.htmlAttrs,s="<"+o,a=""+o+">";return Object.keys(i).forEach((function(e){s+=" "+e+'="'+i[e].replace(/"/g,"'")+'"'})),s+=">",n.attrs.htmlInline?{rawHTML:r?s:a}:{text:""+s+n.attrs.childrenHTML+a}}},tg={strong:{mixable:!0,removedEnclosingWhitespace:!0},emph:{mixable:!0,removedEnclosingWhitespace:!0},strike:{mixable:!0,removedEnclosingWhitespace:!0},code:{escape:!1},link:null,html:null};function ng(e){var t={};return Object.keys(Km).forEach((function(n){t[n]=function(t,r){if(Km[n]){var o=e[n],i=o?o(r,{inTable:t.inTable}):{};!function(e,t){var n=t.state,r=t.nodeInfo,o=t.params,i=o.rawHTML;i?lf()(e,["heading","codeBlock"])>-1?function(e,t,n){var r=n[0],o=n[1];e.write(r),e.convertInline(t),e.write(o)}(n,r.node,i):lf()(e,["image","thematicBreak"])>-1?n.write(i):function(e,t,n){var r=t.node,o=t.parent,i=n[0],s=n[1];e.stopNewline=!0,e.write(i),e.convertNode(r),e.write(s),"doc"===(null==o?void 0:o.type.name)&&(e.closeBlock(r),e.stopNewline=!1)}(n,r,i):Km[e](n,r,o)}(n,{state:t,nodeInfo:r,params:i})}}})),t}function rg(e){Object.keys(e).forEach((function(t){var n=eg[t],r=e[t];eg[t]=n?function(e,t){return t.origin=function(){return n(e,t)},r(e,t)}:r,delete e[t]}));var t=ng(eg),n=function(e){var t={};return Object.keys(tg).forEach((function(n){t[n]=function(t,r){var i=tg[n],s=e[n],a=s&&t&&!Oe()(r)?s(t,{entering:r}):{};return o(o({},a),i)}})),t}(eg);return{nodeTypeConvertors:t,markTypeConvertors:n}}var og=function(){function e(e){var t=e.nodeTypeConvertors,n=e.markTypeConvertors;this.nodeTypeConvertors=t,this.markTypeConvertors=n,this.delim="",this.result="",this.closed=!1,this.tightList=!1,this.stopNewline=!1,this.inTable=!1}return e.prototype.getMarkConvertor=function(e){var t=e.attrs.htmlInline?"html":e.type.name;return this.markTypeConvertors[t]},e.prototype.isInBlank=function(){return/(^|\n)$/.test(this.result)},e.prototype.markText=function(e,t,n,r){var o=this.getMarkConvertor(e);if(o){var i=o({node:e,parent:n,index:r},t),s=i.delim;return i.rawHTML||s}return""},e.prototype.setDelim=function(e){this.delim=e},e.prototype.getDelim=function(){return this.delim},e.prototype.flushClose=function(e){if(!this.stopNewline&&this.closed){if(this.isInBlank()||(this.result+="\n"),e||(e=2),e>1){var t=this.delim,n=/\s+$/.exec(t);n&&(t=t.slice(0,t.length-n[0].length));for(var r=1;rw?a=a.slice(0,w).concat(p).concat(a.slice(w,b)).concat(a.slice(b+1,y)):w>b&&(a=a.slice(0,b).concat(a.slice(b+1,w)).concat(p).concat(a.slice(w,y)));break}}}for(var x=0;x"))})),this.stopNewline=!1,this.inTable=!1},e.prototype.convertNode=function(e,t){var n=this;return e.forEach((function(r,o,i){if(n.convertBlock(r,e,i),(null==t?void 0:t.node)===r){var s=n.result.split("\n");t.setMappedPos([s.length,et(s).length+1])}})),this.result},e}(),ig=og,sg=function(){function e(e,t,n,r){var i=this;this.setMappedPos=function(e){i.mappedPosWhenConverting=e},this.schema=e,this.eventEmitter=r,this.focusedNode=null,this.mappedPosWhenConverting=null,this.toWwConvertors=function(e){var t=Object.keys(e),n=o({},Wm),r=new Xp({gfm:!0,nodeId:!0,convertors:e}).getConvertors();return t.forEach((function(t){var o=Wm[t];o&&!Ue(["htmlBlock","htmlInline"],t)&&(n[t]=function(n,i,s){s.origin=function(){return r[t](i,s,r)};var a,l=e[t](i,s);if(l){var c=Array.isArray(l)?l[0]:l;a={htmlAttrs:c.attributes,classNames:c.classNames}}o(n,i,s,a)})})),n}(n),this.toMdConvertors=rg(t||{}),this.eventEmitter.listen("setFocusedNode",(function(e){return i.focusedNode=e}))}return e.prototype.getMappedPos=function(){return this.mappedPosWhenConverting},e.prototype.getInfoForPosSync=function(){return{node:this.focusedNode,setMappedPos:this.setMappedPos}},e.prototype.toWysiwygModel=function(e){return new Gm(this.schema,this.toWwConvertors).convertNode(e,this.getInfoForPosSync())},e.prototype.toMarkdownText=function(e){var t=new ig(this.toMdConvertors).convertNode(e,this.getInfoForPosSync());return t=this.eventEmitter.emitReduce("beforeConvertWysiwygToMarkdown",t)},e}(),ag=sg;function lg(e){var t=e.plugins,n=e.eventEmitter,r=e.usageStatistics,i=e.instance;return n.listen("mixinTableOffsetMapPrototype",_h),(null!=t?t:[]).reduce((function(e,t){var s=function(e){var t=e.plugin,n={eventEmitter:e.eventEmitter,usageStatistics:e.usageStatistics,instance:e.instance,pmState:{Plugin:an,PluginKey:un,Selection:Vt,TextSelection:Ut},pmView:{Decoration:Vo,DecorationSet:_o},pmModel:{Fragment:d},pmRules:{InputRule:ns,inputRules:rs,undoInputRule:ss},pmKeymap:{keymap:Ai},i18n:hf};if(aa()(t)){var r=t[0],o=t[1];return r(n,void 0===o?{}:o)}return t(n)}({plugin:t,eventEmitter:n,usageStatistics:r,instance:i});if(!s)throw new Error("The return value of the executed plugin is empty.");var a=s.markdownParsers,l=s.toHTMLRenderers,c=s.toMarkdownRenderers,u=s.markdownPlugins,p=s.wysiwygPlugins,h=s.wysiwygNodeViews,f=s.markdownCommands,m=s.wysiwygCommands,g=s.toolbarItems;return l&&(e.toHTMLRenderers=nt(e.toHTMLRenderers,l)),c&&(e.toMarkdownRenderers=nt(e.toMarkdownRenderers,c)),u&&(e.mdPlugins=e.mdPlugins.concat(u)),p&&(e.wwPlugins=e.wwPlugins.concat(p)),h&&(e.wwNodeViews=o(o({},e.wwNodeViews),h)),f&&(e.mdCommands=o(o({},e.mdCommands),f)),m&&(e.wwCommands=o(o({},e.wwCommands),m)),g&&(e.toolbarItems=e.toolbarItems.concat(g)),a&&(e.markdownParsers=o(o({},e.markdownParsers),a)),e}),{toHTMLRenderers:{},toMarkdownRenderers:{},mdPlugins:[],wwPlugins:[],wwNodeViews:{},mdCommands:{},wwCommands:{},toolbarItems:[],markdownParsers:{}})}var cg=function(){function e(e){var t=this;this.options=ve()({linkAttributes:null,extendedAutolinks:!1,customHTMLRenderer:null,referenceDefinition:!1,customHTMLSanitizer:null,frontMatter:!1,usageStatistics:!0,theme:"light"},e),this.eventEmitter=new Im;var n,r=Ge(this.options.linkAttributes),i=lg({plugins:this.options.plugins,eventEmitter:this.eventEmitter,usageStatistics:this.options.usageStatistics,instance:this})||{},s=i.toHTMLRenderers,a=i.markdownParsers,l=this.options,c=l.customHTMLRenderer,u=l.extendedAutolinks,d=l.referenceDefinition,p=l.frontMatter,h=l.customHTMLSanitizer,f={linkAttributes:r,customHTMLRenderer:o(o({},s),c),extendedAutolinks:u,referenceDefinition:d,frontMatter:p,sanitizer:h||rh};n=f.customHTMLRenderer,["htmlBlock","htmlInline"].forEach((function(e){n[e]&&Object.keys(n[e]).forEach((function(e){return nh(e)}))})),this.options.events&&me()(this.options.events,(function(e,n){t.on(n,e)}));var m=this.options,g=m.el,v=m.initialValue,y=m.theme,b=g.innerHTML;"light"!==y&&g.classList.add(xa(y)),g.innerHTML="",this.toastMark=new _p("",{disallowedHtmlBlockTags:["br","img"],extendedAutolinks:u,referenceDefinition:d,disallowDeepHeading:!0,frontMatter:p,customParser:a}),this.preview=new Mh(this.eventEmitter,o(o({},f),{isViewer:!0})),Vc()(this.preview.previewContent,"mousedown",this.toggleTask.bind(this)),v?this.setMarkdown(v):b&&this.preview.setHTML(b),g.appendChild(this.preview.previewContent),this.eventEmitter.emit("load",this)}return e.prototype.toggleTask=function(e){var t=e.target,n=getComputedStyle(t,":before");!t.hasAttribute("data-task-disabled")&&t.hasAttribute("data-task")&&wa(n,e.offsetX,e.offsetY)&&(Sa(t,"checked"),this.eventEmitter.emit("change",{source:"viewer",date:e}))},e.prototype.setMarkdown=function(e){var t=this.toastMark.getLineTexts(),n=[t.length,et(t).length+1],r=this.toastMark.editMarkdown([1,1],n,e||"");this.eventEmitter.emit("updatePreview",r)},e.prototype.on=function(e,t){this.eventEmitter.listen(e,t)},e.prototype.off=function(e){this.eventEmitter.removeEventHandler(e)},e.prototype.addHook=function(e,t){this.eventEmitter.removeEventHandler(e),this.eventEmitter.listen(e,t)},e.prototype.destroy=function(){zc()(this.preview.el,"mousedown",this.toggleTask.bind(this)),this.preview.destroy(),this.eventEmitter.emit("destroy")},e.prototype.isViewer=function(){return!0},e.prototype.isMarkdownMode=function(){return!1},e.prototype.isWysiwygMode=function(){return!1},e}(),ug=cg;function dg(e){return e instanceof P}function pg(e){return Ue(["document","blockQuote","bulletList","orderedList","listItem","paragraph","heading","emph","strong","strike","link","image","table","tableHead","tableBody","tableRow","tableHeadCell","tableBodyCell"],e)}var hg={openTag:function(e,t){var n=e,r=n.tagName,i=n.classNames,s=n.attributes,a=document.createElement(r),l={};i&&(a.className=i.join(" ")),s&&(l=o(o({},l),s)),Aa(l,a),t.push(a)},closeTag:function(e,t){if(t.length>1){var n=t.pop();et(t).appendChild(n)}},html:function(e,t){et(t).insertAdjacentHTML("beforeend",e.content)},text:function(e,t){var n=document.createTextNode(e.content);et(t).appendChild(n)}},fg=function(){function e(e,t){var n=dh(e,t),r=o(o({},t.htmlBlock),t.htmlInline);this.customConvertorKeys=Object.keys(t).concat(Object.keys(r)),this.renderer=new Xp({gfm:!0,convertors:o(o({},n),r)}),this.convertors=this.renderer.getConvertors()}return e.prototype.generateTokens=function(e){var t=function(e){var t=e.attrs,n=e.type.name,r={type:n,wysiwygNode:!0,literal:!pg(n)&&dg(e)?e.textContent:null},i={heading:{level:t.level},link:{destination:t.linkUrl,title:t.title},image:{destination:t.imageUrl},codeBlock:{info:t.language},bulletList:{type:"list",listData:{type:"bullet"}},orderedList:{type:"list",listData:{type:"ordered",start:t.order}},listItem:{type:"item",listData:{task:t.task,checked:t.checked}},tableHeadCell:{type:"tableCell",cellType:"head",align:t.align},tableBodyCell:{type:"tableCell",cellType:"body",align:t.align},customBlock:{info:t.info}}[n],s=o(o({},r),i),a=e.attrs,l=a.htmlAttrs,c=a.childrenHTML;return l?o(o({},s),{attrs:l,childrenHTML:c}):s}(e),n={entering:!0,leaf:!!dg(e)&&e.isLeaf,options:this.renderer.getOptions(),getChildrenText:function(){return dg(e)?e.textContent:""},skipChildren:function(){return!1}},r=this.convertors[e.type.name],i=r(t,n,this.convertors),s=aa()(i)?i:[i];return(pg(e.type.name)||e.attrs.htmlInline)&&(n.entering=!1,s.push({type:"text",content:dg(e)?e.textContent:""}),s=s.concat(r(t,n,this.convertors))),s},e.prototype.toDOMNode=function(e){var t=this.generateTokens(e),n=[];return t.forEach((function(e){return hg[e.type](e,n)})),n[0]},e.prototype.getToDOMNode=function(e){return Ue(this.customConvertorKeys,e)?this.toDOMNode.bind(this):null},e}(),mg=null,gg=null;function vg(e,t){var n=t.syncScrollTop,r=t.releaseEventBlock;gg&&clearTimeout(gg),n(e),gg=setTimeout((function(){r()}),15)}var yg=function(){function e(e,t,n){this.latestEditorScrollTop=null,this.latestPreviewScrollTop=null,this.blockedScroll=null,this.active=!0,this.timer=null;var r=t.previewContent,o=t.el;this.previewRoot=r,this.previewEl=o,this.mdEditor=e,this.editorView=e.view,this.toastMark=e.getToastMark(),this.eventEmitter=n,this.addScrollSyncEvent()}return e.prototype.addScrollSyncEvent=function(){var e=this;this.eventEmitter.listen("afterPreviewRender",(function(){e.clearTimer(),e.timer=setTimeout((function(){e.syncPreviewScrollTop(!0)}),200)})),this.eventEmitter.listen("scroll",(function(t,n){e.active&&("editor"===t&&"editor"!==e.blockedScroll?e.syncPreviewScrollTop():"preview"===t&&"preview"!==e.blockedScroll&&e.syncEditorScrollTop(n))})),this.eventEmitter.listen("toggleScrollSync",(function(t){e.active=t}))},e.prototype.getMdNodeAtPos=function(e,t){var n=e.content.findIndex(t.pos).index;return this.toastMark.findFirstNodeAtLine(n+1)},e.prototype.getScrollTopByCaretPos=function(){var e=this.mdEditor.getSelection(),t=this.toastMark.findFirstNodeAtLine(e[0][0]),n=this.previewEl.clientHeight,r=bh(this.previewRoot,t).el,o=(vh(r,this.previewRoot)||r.offsetTop)+r.clientHeight-.5*n;return this.latestEditorScrollTop=null,r.getBoundingClientRect().top-this.previewEl.getBoundingClientRect().topT.top?Math.min((a-T.top)/C,1):0)}y=this.getResolvedScrollTop("editor",h,y,d),this.latestEditorScrollTop=h}y!==d&&this.run("editor",y,d)}},e.prototype.syncEditorScrollTop=function(e){var t=this,n=t.toastMark,r=t.editorView,o=t.previewRoot,i=t.previewEl,s=r.dom,a=r.state,l=i.scrollTop,c=i.clientHeight,u=i.scrollHeight-l<=c,d=s.scrollTop,p=u?s.scrollHeight:0;if(l&&e&&!u){if(e=function(e,t){for(;!e.getAttribute("data-nodeid")&&e.parentElement!==t;)e=e.parentElement;return e}(e,o),!e.getAttribute("data-nodeid"))return;var h=s.children,f=Number(e.getAttribute("data-nodeid")),m=bh(this.previewRoot,n.findNodeById(f)),g=m.mdNode,v=m.el;p=h[Is(g)-1].offsetTop;var y=mh(a.doc,g,h).height,b=xh(v,o,f),w=b.nodeHeight;p+=function(e,t,n,r){return Math.min((e-t)/n,1)*r}(l,b.offsetTop,w,y),p=this.getResolvedScrollTop("preview",l,p,d),this.latestPreviewScrollTop=l}p!==d&&this.run("preview",p,d)},e.prototype.getResolvedScrollTop=function(e,t,n,r){var o="editor"===e?this.latestEditorScrollTop:this.latestPreviewScrollTop;return null===o?n:o <\/p>/gi,"
"),n=new RegExp(ma,"ig"),r=t.match(n);return null==r||r.forEach((function(e,n){if(va.test(e)){var o=ba;if(n){var i=r[n-1].match(fa);if(i&&!/br/i.test(i[1])){var s=i[1];o=""+s+"><"+s+">"}}t=t.replace(va,o)}})),t}(e);var r=te.fromSchema(this.wwEditor.schema).parse(n);this.isMarkdownMode()?this.mdEditor.setMarkdown(this.convertor.toMarkdownText(r),t):this.wwEditor.setModel(r,t)},e.prototype.getMarkdown=function(){return this.isMarkdownMode()?this.mdEditor.getMarkdown():this.convertor.toMarkdownText(this.wwEditor.getModel())},e.prototype.getHTML=function(){var e=this;this.eventEmitter.holdEventInvoke((function(){if(e.isMarkdownMode()){var t=e.toastMark.getRootNode(),n=e.convertor.toWysiwygModel(t);e.wwEditor.setModel(n)}}));var t=La(this.wwEditor.view.dom.innerHTML);if(this.placeholder){var n=new RegExp('","i");return t.replace(n,"")}return t},e.prototype.insertText=function(e){this.getCurrentModeEditor().replaceSelection(e)},e.prototype.setSelection=function(e,t){this.getCurrentModeEditor().setSelection(e,t)},e.prototype.replaceSelection=function(e,t,n){this.getCurrentModeEditor().replaceSelection(e,t,n)},e.prototype.deleteSelection=function(e,t){this.getCurrentModeEditor().deleteSelection(e,t)},e.prototype.getSelectedText=function(e,t){return this.getCurrentModeEditor().getSelectedText(e,t)},e.prototype.getRangeInfoOfNode=function(e){return this.getCurrentModeEditor().getRangeInfoOfNode(e)},e.prototype.addWidget=function(e,t,n){this.getCurrentModeEditor().addWidget(e,t,n)},e.prototype.replaceWithWidget=function(e,t,n){this.getCurrentModeEditor().replaceWithWidget(e,t,n)},e.prototype.setHeight=function(e){var t=this.options.el;Me()(e)&&("auto"===e?ke()(t,"auto-height"):Ce()(t,"auto-height"),this.setMinHeight(this.getMinHeight())),be()(t,{height:e}),this.height=e},e.prototype.getHeight=function(){return this.height},e.prototype.setMinHeight=function(e){if(e!==this.minHeight){var t=this.height||this.options.height;"auto"!==t&&this.options.el.querySelector("."+xa("main"))&&(e=Math.min(parseInt(e,10),parseInt(t,10)-75)+"px");var n=parseInt(e,10);this.minHeight=e,this.wwEditor.setMinHeight(n),this.mdEditor.setMinHeight(n),this.preview.setMinHeight(n)}},e.prototype.getMinHeight=function(){return this.minHeight},e.prototype.isMarkdownMode=function(){return"markdown"===this.mode},e.prototype.isWysiwygMode=function(){return"wysiwyg"===this.mode},e.prototype.isViewer=function(){return!1},e.prototype.getCurrentPreviewStyle=function(){return this.mdPreviewStyle},e.prototype.changeMode=function(e,t){if(this.mode!==e){if(this.mode=e,this.isWysiwygMode()){var n=this.toastMark.getRootNode(),r=this.convertor.toWysiwygModel(n);this.wwEditor.setModel(r)}else{r=this.wwEditor.getModel();this.mdEditor.setMarkdown(this.convertor.toMarkdownText(r),!t)}if(this.eventEmitter.emit("removePopupWidget"),this.eventEmitter.emit("changeMode",e),!t){var o=this.convertor.getMappedPos();this.focus(),this.isWysiwygMode()&&Ee()(o)?this.wwEditor.setSelection(o):Array.isArray(o)&&this.mdEditor.setSelection(o)}}},e.prototype.destroy=function(){var e=this;this.wwEditor.destroy(),this.mdEditor.destroy(),this.preview.destroy(),this.scrollSync.destroy(),this.eventEmitter.emit("destroy"),this.eventEmitter.getEvents().forEach((function(t,n){return e.off(n)}))},e.prototype.hide=function(){this.eventEmitter.emit("hide")},e.prototype.show=function(){this.eventEmitter.emit("show")},e.prototype.setScrollTop=function(e){this.getCurrentModeEditor().setScrollTop(e)},e.prototype.getScrollTop=function(){return this.getCurrentModeEditor().getScrollTop()},e.prototype.reset=function(){this.wwEditor.setModel([]),this.mdEditor.setMarkdown("")},e.prototype.getSelection=function(){return this.getCurrentModeEditor().getSelection()},e.prototype.setPlaceholder=function(e){this.placeholder=e,this.mdEditor.setPlaceholder(e),this.wwEditor.setPlaceholder(e)},e.prototype.getEditorElements=function(){return{mdEditor:this.mdEditor.getElement(),mdPreview:this.preview.getElement(),wwEditor:this.wwEditor.getElement()}},e.prototype.convertPosToMatchEditorMode=function(e,t,n){var r,o;void 0===t&&(t=e),void 0===n&&(n=this.mode);var i=this.mdEditor.view.state.doc,s=Array.isArray(e),a=Array.isArray(t),l=e,c=t;if(s!==a)throw new Error("Types of arguments must be same");return"markdown"!==n||s||a?"wysiwyg"===n&&s&&a&&(l=(o=ol(i,e,t))[0],c=o[1]):(l=(r=nl(i,e,t))[0],c=r[1]),[l,c]},e}(),kg=wg;var xg=n(326),Cg=n.n(xg),Tg=function(){function e(e){this.current=e,this.root=e,this.entering=!0}return e.prototype.walk=function(){var e=this.entering,t=this.current;return t?(e?t.firstChild?(this.current=t.firstChild,this.entering=!0):this.entering=!1:t===this.root?this.current=null:t.next?(this.current=t.next,this.entering=!0):(this.current=t.parent,this.entering=!1),{vnode:t,entering:e}):null},e}(),Mg=function(){function e(e,t,n){this.parent=null,this.old=null,this.firstChild=null,this.next=null,this.skip=!1,this.type=e,this.props=t,this.children=n,this.props.children=n,t.ref&&(this.ref=t.ref,delete t.ref),t.key&&(this.key=t.key,delete t.key)}return e.prototype.walker=function(){return new Tg(this)},e.removalNodes=[],e}();function Sg(e,t){var n,r=e;Cg()(e)||null==e?r=null:(Me()(e)||Ee()(e))&&(n=String(e),r=new Mg("TEXT_NODE",{nodeValue:n},[])),r&&t.push(r)}var Eg=function(e){for(var t,n,r=arguments,o=1,i="",s="",a=[0],l=function(e){1===o&&(e||(i=i.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(e?r[e]:i):3===o&&(e||i)?(a[1]=e?r[e]:i,o=2):2===o&&"..."===i&&e?a[2]=it(a[2]||{},r[e]):2===o&&i&&!e?(a[2]=a[2]||{})[i]=!0:o>=5&&(5===o?((a[2]=a[2]||{})[n]=e?i?i+r[e]:r[e]:i,o=6):(e||i)&&(a[2][n]+=e?i+r[e]:i)),i=""},c=0;c"===t?(o=1,i=""):i=t+i[0]:s?t===s?s="":i+=t:'"'===t||"'"===t?s=t:">"===t?(l(),o=1):o&&("="===t?(o=5,n=i,i=""):"/"===t&&(o<5||">"===e[c][u+1])?(l(),3===o&&(a=a[0]),o=a,(a=a[0]).push(this.apply(null,o.slice(1))),o=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(l(),o=2):i+=t),3===o&&"!--"===i&&(o=4,a=a[0])}return l(),a.length>2?a.slice(1):a[1]}.bind((function(e,t){for(var n=[],r=2;r\n \n
\n
\n
0},e}(),pv="undefined"!=typeof WeakMap?new WeakMap:new Jg,hv=function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var n=Yg.getInstance(),r=new dv(t,n,this);pv.set(this,r)};["observe","unobserve","disconnect"].forEach((function(e){hv.prototype[e]=function(){var t;return(t=pv.get(this))[e].apply(t,arguments)}}));var fv,mv,gv,vv,yv,bv,wv,kv,xv,Cv,Tv,Mv,Sv,Ev,Nv,Ov,Dv=void 0!==Kg.ResizeObserver?Kg.ResizeObserver:hv,Av=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),n.prototype.execCommand=function(e){var t=Oa(e.target,"li");this.props.execCommand("heading",{level:Number(t.getAttribute("data-level"))})},n.prototype.render=function(){var e=this;return Eg(mv||(mv=s(["\n
\n "],["\n
\n "])),(function(t){return e.execCommand(t)}),hf.get("Headings"),[1,2,3,4,5,6].map((function(e){return Eg(fv||(fv=s(['\n
\n <',">"," ","$>\n \n "],['\n
\n <',">"," ","$>\n \n "])),e,"h"+e,hf.get("Heading"),e)})),hf.get("Paragraph"))},n}($g),Lv=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),n.prototype.toggleTab=function(e,t){this.props.onClick(e,t)},n.prototype.render=function(){var e=this;return Eg(vv||(vv=s(['\n
\n ',"\n
\n "],['\n
\n ',"\n
\n "])),xa("tabs"),this.props.tabs.map((function(t){var n=t.name,r=t.text,o=e.props.activeTab===n;return Eg(gv||(gv=s(['\n
\n ',"\n
\n "],['\n
\n ',"\n
\n "])),o?" active":"",(function(t){return e.toggleTab(t,n)}),hf.get(r),o?"true":"false",o?"0":"-1",hf.get(r))})))},n}($g),Iv=function(e){function n(t){var n=e.call(this,t)||this;return n.initialize=function(e){void 0===e&&(e="file");var t=n.refs.url;t.value="",n.refs.altText.value="",n.refs.file.value="",Ce()(t,"wrong"),n.setState({activeTab:e,file:null,fileNameElClassName:""})},n.execCommand=function(){"file"===n.state.activeTab?n.emitAddImageBlob():n.emitAddImage()},n.toggleTab=function(e,t){t!==n.state.activeTab&&n.initialize(t)},n.showFileSelectBox=function(){n.refs.file.click()},n.changeFile=function(e){var t=e.target.files;(null==t?void 0:t.length)&&n.setState({file:t[0]})},n.state={activeTab:"file",file:null,fileNameElClassName:""},n.tabs=[{name:"file",text:"File"},{name:"url",text:"URL"}],n}return t(n,e),n.prototype.emitAddImageBlob=function(){var e=this,t=this.refs.file.files,n=this.refs.altText,r=" wrong";if(null==t?void 0:t.length){r="";var o=t.item(0);this.props.eventEmitter.emit("addImageBlobHook",o,(function(t,r){return e.props.execCommand("addImage",{imageUrl:t,altText:r||n.value})}),"ui")}this.setState({fileNameElClassName:r})},n.prototype.emitAddImage=function(){var e=this.refs.url,t=this.refs.altText,n=e.value,r=t.value||"image";Ce()(e,"wrong"),n.length?n&&this.props.execCommand("addImage",{imageUrl:n,altText:r}):ke()(e,"wrong")},n.prototype.preventSelectStart=function(e){e.preventDefault()},n.prototype.updated=function(){this.props.show||this.initialize()},n.prototype.render=function(){var e=this,t=this.state,n=t.activeTab,r=t.file,o=t.fileNameElClassName;return Eg(yv||(yv=s(['\n
\n <'," tabs="," activeTab="," onClick=",' />\n
\n ',' \n \n
\n
\n ',' \n \n \n \n
\n
',' \n
\n
\n
\n \n <'," tabs="," activeTab="," onClick=",' />\n \n ',' \n \n
\n \n ',' \n \n \n \n
\n ',' \n \n \n
\n \n ',' \n \n ',' \n \n \n
\n \n ',' \n \n ',' \n \n \n \n
'],['
'])),l))}r.push(Eg(kv||(kv=s(['',"
"],['',"
"])),xa("table-row"),i))}return Eg(xv||(xv=s(['',"
"],['',"
"])),xa("table"),r)},n.prototype.render=function(){var e=this,t=this.getTableRange(),n=this.getSelectionAreaBound();return Eg(Cv||(Cv=s(['\n \n ',"
\n \n "],['\n \n ',"
\n \n "])),hf.get("Insert table"),xa("table-selection"),(function(t){return e.refs.tableEl=t}),this.extendSelectionRange,this.execCommand,this.createTableArea(t),xa("table-selection-layer"),n,xa("table-description"),this.getDescription())},n}($g),Bv=function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return t(n,e),n.prototype.mounted=function(){this.refs.el.appendChild(this.props.body)},n.prototype.updated=function(e){this.refs.el.replaceChild(this.props.body,e.body)},n.prototype.render=function(){var e=this;return Eg(Tv||(Tv=s(["
"],["
"])),(function(t){return e.refs.el=t}))},n}($g);function Fv(e){return Me()(e)?function(e){var t;switch(e){case"heading":t={name:"heading",className:"heading",tooltip:hf.get("Headings"),state:"heading"};break;case"bold":t={name:"bold",className:"bold",command:"bold",tooltip:hf.get("Bold"),state:"strong"};break;case"italic":t={name:"italic",className:"italic",command:"italic",tooltip:hf.get("Italic"),state:"emph"};break;case"strike":t={name:"strike",className:"strike",command:"strike",tooltip:hf.get("Strike"),state:"strike"};break;case"hr":t={name:"hr",className:"hrline",command:"hr",tooltip:hf.get("Line"),state:"thematicBreak"};break;case"quote":t={name:"quote",className:"quote",command:"blockQuote",tooltip:hf.get("Blockquote"),state:"blockQuote"};break;case"ul":t={name:"ul",className:"bullet-list",command:"bulletList",tooltip:hf.get("Unordered list"),state:"bulletList"};break;case"ol":t={name:"ol",className:"ordered-list",command:"orderedList",tooltip:hf.get("Ordered list"),state:"orderedList"};break;case"task":t={name:"task",className:"task-list",command:"taskList",tooltip:hf.get("Task"),state:"taskList"};break;case"table":t={name:"table",className:"table",tooltip:hf.get("Insert table"),state:"table"};break;case"image":t={name:"image",className:"image",tooltip:hf.get("Insert image")};break;case"link":t={name:"link",className:"link",tooltip:hf.get("Insert link")};break;case"code":t={name:"code",className:"code",command:"code",tooltip:hf.get("Code"),state:"code"};break;case"codeblock":t={name:"codeblock",className:"codeblock",command:"codeBlock",tooltip:hf.get("Insert CodeBlock"),state:"codeBlock"};break;case"indent":t={name:"indent",className:"indent",command:"indent",tooltip:hf.get("Indent"),state:"indent"};break;case"outdent":t={name:"outdent",className:"outdent",command:"outdent",tooltip:hf.get("Outdent"),state:"outdent"};break;case"scrollSync":t=function(){var e=document.createElement("label"),t=document.createElement("input"),n=document.createElement("span");e.className="scroll-sync active",t.type="checkbox",t.checked=!0,n.className="switch";var r=function(n){return t.addEventListener("change",(function(t){var r=t.target.checked;r?ke()(e,"active"):Ce()(e,"active"),n("toggleScrollSync",{active:r})}))};return e.appendChild(t),e.appendChild(n),{name:"scrollSync",el:e,onMounted:r}}();break;case"more":t={name:"more",className:"more",tooltip:hf.get("More")}}"scrollSync"!==t.name&&(t.className+=" "+xa("toolbar-icons"));return t}(e):e}function Hv(e,t){var n=t.el,r=t.pos,i=t.popup,a=t.initialValues;switch(e){case"heading":return{render:function(e){return Eg(Mv||(Mv=s(["<"," ..."," />"],["<"," ..."," />"])),Av,e)},className:xa("popup-add-heading"),fromEl:n,pos:r};case"link":return{render:function(e){return Eg(Sv||(Sv=s(["<"," ..."," />"],["<"," ..."," />"])),Rv,e)},className:xa("popup-add-link"),fromEl:n,pos:r,initialValues:a};case"image":return{render:function(e){return Eg(Ev||(Ev=s(["<"," ..."," />"],["<"," ..."," />"])),Iv,e)},className:xa("popup-add-image"),fromEl:n,pos:r};case"table":return{render:function(e){return Eg(Nv||(Nv=s(["<"," ..."," />"],["<"," ..."," />"])),Pv,e)},className:xa("popup-add-table"),fromEl:n,pos:r};case"customPopupBody":return i?o({render:function(e){return Eg(Ov||(Ov=s(["<"," ..."," body="," />"],["<"," ..."," body="," />"])),Bv,e,i.body)},fromEl:n,pos:r},i):null;default:return null}}function zv(e){e.hidden=e.length===e.filter((function(e){return e.hidden})).length}function qv(e,t){return e.reduce((function(e,n){e.push(n.map((function(e){return function(e){return e.hidden="scrollSync"===e.name&&t,e}(Fv(e))})));var r=e[(e.length||1)-1];return r&&zv(r),e}),[])}var Vv,jv,$v=function(e){function n(){var t=null!==e&&e.apply(this,arguments)||this;return t.handleMousedown=function(e){Oa(e.target,"."+xa("popup"))||Oa(e.target,t.props.info.fromEl)||t.props.hidePopup()},t}return t(n,e),n.prototype.mounted=function(){document.addEventListener("mousedown",this.handleMousedown),this.props.eventEmitter.listen("closePopup",this.props.hidePopup)},n.prototype.beforeDestroy=function(){document.removeEventListener("mousedown",this.handleMousedown)},n.prototype.updated=function(e){var t=this.props,n=t.show,r=t.info;if(n&&r.pos&&e.show!==n){var i=o({},r.pos),s=this.refs.el.offsetWidth,a=Oa(this.refs.el,"."+xa("toolbar")).offsetWidth;i.left+s>=a&&(i.left=a-s-20),Ye(this.state.popupPos,i)||this.setState({popupPos:i})}},n.prototype.render=function(){var e=this,t=this.props,n=t.info,r=t.show,i=t.hidePopup,a=t.eventEmitter,l=t.execCommand,c=n||{},u=c.className,d=void 0===u?"":u,p=c.style,h=c.render,f=c.initialValues,m=void 0===f?{}:f,g=o(o({display:r?"block":"none"},p),this.state.popupPos);return Eg(Vv||(Vv=s(['\n \n "],['\n \n "])),xa("popup"),d,g,(function(t){return e.refs.el=t}),xa("popup-body"),h&&h({eventEmitter:a,show:r,hidePopup:i,execCommand:l,initialValues:m}))},n}($g);function _v(e){return function(n){function r(e){var t=n.call(this,e)||this;return t.showTooltip=function(e){var n=t.props.item.tooltip;if(!t.props.disabled&&n){var r=t.getBound(e),o=r.left+6+"px",i=r.top+6+"px";be()(t.props.tooltipRef.current,{display:"block",left:o,top:i}),t.props.tooltipRef.current.querySelector(".text").textContent=n}},t.hideTooltip=function(){be()(t.props.tooltipRef.current,"display","none")},t.state={active:!1,disabled:e.disabled},t.addEvent(),t}return t(r,n),r.prototype.addEvent=function(){var e=this,t=this.props,n=t.item,r=t.eventEmitter;n.state&&r.listen("changeToolbarState",(function(t){var r,o=null!==(r=t.toolbarState[n.state])&&void 0!==r?r:{},i=o.active,s=o.disabled;e.setState({active:!!i,disabled:null!=s?s:e.props.disabled})}))},r.prototype.getBound=function(e){var t=Da(e,Oa(e,"."+xa("toolbar"))),n=t.offsetLeft,r=t.offsetTop;return{left:n,top:e.offsetHeight+r}},r.prototype.render=function(){return Eg(jv||(jv=s(["\n <","\n ...","\n active=","\n showTooltip=","\n hideTooltip=","\n getBound=","\n disabled=","\n />\n "],["\n <","\n ...","\n active=","\n showTooltip=","\n hideTooltip=","\n getBound=","\n disabled=","\n />\n "])),e,this.props,this.state.active,this.showTooltip,this.hideTooltip,this.getBound,this.state.disabled||this.props.disabled)},r}($g)}var Uv,Wv,Jv,Gv,Kv,Zv,Xv,Qv,Yv,ey,ty,ny,ry,oy,iy=function(e){function n(){var t=null!==e&&e.apply(this,arguments)||this;return t.showTooltip=function(){t.props.showTooltip(t.refs.el)},t.execCommand=function(){var e=t.props,n=e.item,r=e.execCommand,o=e.setPopupInfo,i=e.getBound,s=e.eventEmitter,a=n.command,l=n.name,c=n.popup;if(a)r(a);else{var u=c?"customPopupBody":l,d=s.emit("query","getPopupInitialValues",{popupName:u})[0],p=Hv(u,{el:t.refs.el,pos:i(t.refs.el),popup:c,initialValues:d});p&&o(p)}},t}return t(n,e),n.prototype.mounted=function(){this.setItemWidth()},n.prototype.updated=function(e){e.item.name!==this.props.item.name&&this.setItemWidth()},n.prototype.setItemWidth=function(){var e=this.props,t=e.setItemWidth,n=e.item;t&&t(n.name,Na(this.refs.el)+(n.hidden?80:0))},n.prototype.render=function(){var e=this,t=this.props,n=t.hideTooltip,r=t.disabled,i=t.item,a=t.active,l=o({display:i.hidden?"none":null},i.style),c=(i.className||"")+(a?" active":"");return Eg(Uv||(Uv=s(["\n \n \n
';this.tooltipRef.current=Ea(e,this.refs.el)},n.prototype.hiddenScrollSync=function(){return"wysiwyg"===this.props.editorType||"tab"===this.props.previewStyle},n.prototype.movePrevItemToDropdownToolbar=function(e,t,n,r){var o=function(e){var t=e.pop();t&&r.push(t)};if(e>1)o(n);else{var i=et(t);i&&o(i)}},n.prototype.classifyToolbarItems=function(){var e=this,t=0,n=this.refs.el.clientWidth,r=this.refs.el.querySelector("."+xa("toolbar-divider")),o=r?Na(r):0,i=[],s=[],a=!1;return this.initialItems.forEach((function(r,l){var c=[],u=[];r.forEach((function(r,o){r.hidden||((t+=e.itemWidthMap[r.name])>n-50?(a||(e.movePrevItemToDropdownToolbar(o,i,c,u),a=!0),u.push(r)):c.push(r))})),c.length&&(zv(c),i.push(c)),u.length&&(zv(u),s.push(u)),l
\n \n <'," tabs="," activeTab="," onClick=",' />\n
\n \n
\n <'," tabs="," activeTab="," onClick=",' />\n
\n
\n
'," \n \n "],["\n
\n '," \n \n "])),(function(){a||(l(),e.setState({pos:null}))}),a?" disabled":"",i,n))})),t.push(Eg(ey||(ey=s(['"],['"])),r)),t}),[]):[]},n.prototype.render=function(){var e=o({display:this.state.pos?"block":"none"},this.state.pos);return Eg(ty||(ty=s(['
\n ',"\n
"],['
\n ',"\n
"])),xa("context-menu"),e,this.getMenuGroupElements())},n}($g),fy=function(e){function n(t){var n=e.call(this,t)||this;n.changeMode=function(e){e!==n.state.editorType&&n.setState({editorType:e})},n.changePreviewStyle=function(e){e!==n.state.previewStyle&&n.setState({previewStyle:e})},n.hide=function(){n.setState({hide:!0})},n.show=function(){n.setState({hide:!1})};var r=t.editorType,o=t.previewStyle;return n.state={editorType:r,previewStyle:o,hide:!1},n.addEvent(),n}return t(n,e),n.prototype.mounted=function(){var e=this.props.slots,t=e.wwEditor,n=e.mdEditor,r=e.mdPreview;this.refs.wwContainer.appendChild(t),this.refs.mdContainer.insertAdjacentElement("afterbegin",n),this.refs.mdContainer.appendChild(r)},n.prototype.insertToolbarItem=function(e,t){this.toolbar.insertToolbarItem(e,t)},n.prototype.removeToolbarItem=function(e){this.toolbar.removeToolbarItem(e)},n.prototype.render=function(){var e=this,t=this.props,n=t.eventEmitter,r=t.hideModeSwitch,o=t.toolbarItems,i=t.theme,a=this.state,l=a.hide,c=a.previewStyle,u=a.editorType,d=l?" hidden":"",p=xa("markdown"===u?"md-mode":"ww-mode"),h=xa("md")+"-"+c+"-style",f=xa(["light"!==i,i+" "]);return Eg(ry||(ry=s(['\n
\n
\n
\n
\n
\n
\n
\n
\n
= 5:
+ return "Hoch"
+ case priority > 0:
+ return "Erhöht"
+ case priority < 0:
+ return "Niedrig"
+ default:
+ return "Normal"
+ }
+}
+
+func configuredPriorityName(priorities []client.TaskPriority, priority int) string {
+ for _, option := range priorities {
+ if option.Value == priority {
+ return option.Name
+ }
+ }
+ return priorityName(priority)
+}
+
+type statusOption struct {
+ Value string
+ Label string
+}
+
+func plantStatuses() []statusOption {
+ return []statusOption{
+ {Value: "alive", Label: "Lebendig"},
+ {Value: "dead", Label: "Tot"},
+ {Value: "removed", Label: "Entfernt"},
+ {Value: "infested", Label: "Befallen"},
+ {Value: "harvested", Label: "Geerntet"},
+ }
+}
+
+func plantStatusName(status string) string {
+ for _, option := range plantStatuses() {
+ if option.Value == status {
+ return option.Label
+ }
+ }
+ return status
+}
+
+func careStatuses() []statusOption {
+ return []statusOption{{Value: "good", Label: "Gut"}, {Value: "bad", Label: "Schlecht"}, {Value: "untested", Label: "Ungetestet"}, {Value: "testing", Label: "In Testung"}, {Value: "planned", Label: "Geplant"}}
+}
+
+func lifecycleName(value *string) string {
+ if value == nil {
+ return ""
+ }
+ switch *value {
+ case "annual":
+ return "Einjährig"
+ case "biennial":
+ return "Zweijährig"
+ case "perennial":
+ return "Mehrjährig"
+ default:
+ return ""
+ }
+}
+
+type monthOption struct {
+ Value int
+ Label string
+}
+
+func months() []monthOption {
+ return []monthOption{
+ {Value: 1, Label: "Januar"}, {Value: 2, Label: "Februar"},
+ {Value: 3, Label: "März"}, {Value: 4, Label: "April"},
+ {Value: 5, Label: "Mai"}, {Value: 6, Label: "Juni"},
+ {Value: 7, Label: "Juli"}, {Value: 8, Label: "August"},
+ {Value: 9, Label: "September"}, {Value: 10, Label: "Oktober"},
+ {Value: 11, Label: "November"}, {Value: 12, Label: "Dezember"},
+ }
+}
+
+func recurrenceName(recurrence string) string {
+ switch recurrence {
+ case "daily":
+ return "Täglich"
+ case "weekly":
+ return "Wöchentlich"
+ case "monthly":
+ return "Monatlich"
+ case "yearly":
+ return "Jährlich"
+ default:
+ return ""
+ }
+}
+
+func recurrenceDescription(recurrence string, interval int) string {
+ if recurrence == "" {
+ return ""
+ }
+ if interval < 1 {
+ interval = 1
+ }
+ units := map[string][2]string{"daily": {"Tag", "Tage"}, "weekly": {"Woche", "Wochen"}, "monthly": {"Monat", "Monate"}, "yearly": {"Jahr", "Jahre"}}
+ unit, ok := units[recurrence]
+ if !ok {
+ return ""
+ }
+ label := unit[1]
+ if interval == 1 {
+ label = unit[0]
+ }
+ return fmt.Sprintf("Alle %d %s", interval, label)
+}
+
+func durationDescription(amount int, unit string) string {
+ units := map[string][2]string{"day": {"Tag", "Tage"}, "week": {"Woche", "Wochen"}, "month": {"Monat", "Monate"}}
+ labels, ok := units[unit]
+ if !ok {
+ return ""
+ }
+ label := labels[1]
+ if amount == 1 {
+ label = labels[0]
+ }
+ return fmt.Sprintf("%d %s", amount, label)
+}
+
+func plantTaskData(garden *client.Garden, task plantTaskForm) *templateData {
+ return &templateData{commonTemplateData: commonTemplateData{Garden: garden}, plantTemplateData: plantTemplateData{PlantTasks: []plantTaskForm{task}}}
+}
+
+var functions = template.FuncMap{
+ "webPath": webPath,
+ "pathWithQuery": pathWithQuery,
+ "gardenAwarePath": gardenAwarePath,
+ "dict": func(values ...any) map[string]any {
+ result := map[string]any{}
+ for i := 0; i+1 < len(values); i += 2 {
+ key, _ := values[i].(string)
+ result[key] = values[i+1]
+ }
+ return result
+ },
+ "humanDate": humanDate,
+ "journalDate": func(value time.Time) string { return value.Local().Format("02.01.2006 · 15:04 Uhr") },
+ "fileSize": func(value int64) string {
+ if value >= 1<<20 {
+ return fmt.Sprintf("%.1f MB", float64(value)/(1<<20))
+ }
+ if value >= 1<<10 {
+ return fmt.Sprintf("%.1f kB", float64(value)/(1<<10))
+ }
+ return fmt.Sprintf("%d B", value)
+ },
+ "hasPrefix": strings.HasPrefix,
+ "markdown": func(value string) template.HTML {
+ var output bytes.Buffer
+ parser := goldmark.New(goldmark.WithExtensions(extension.GFM))
+ if err := parser.Convert([]byte(value), &output); err != nil {
+ return template.HTML(template.HTMLEscapeString(value))
+ }
+ return template.HTML(output.String())
+ },
+ "speciesName": speciesName,
+ "plantName": plantName,
+ "locationName": locationName,
+ "taskDue": taskDue,
+ "taskDueDate": taskDueDate,
+ "priorityName": priorityName,
+ "configuredPriorityName": configuredPriorityName,
+ "plantStatuses": plantStatuses,
+ "plantStatusName": plantStatusName,
+ "careStatuses": careStatuses,
+ "lifecycleName": lifecycleName,
+ "months": months,
+ "recurrenceName": recurrenceName,
+ "recurrenceDescription": recurrenceDescription,
+ "durationDescription": durationDescription,
+ "plantTaskData": plantTaskData,
+ "calendarDate": func(value time.Time) string { return value.Format("02.01.2006") },
+ "dateValue": func(value time.Time) string { return value.Format("2006-01-02") },
+ "eqInt": func(left, right int) bool { return left == right },
+ "neInt": func(left, right int) bool { return left != right },
+ "containsInt": func(values []int, target int) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+ },
+ "containsString": func(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+ },
+ "add": func(left, right int) int { return left + right },
+ "sub": func(left, right int) int { return left - right },
+ "eqString": func(left, right string) bool { return left == right },
+ "neString": func(left, right string) bool { return left != right },
+ "stringValue": func(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+ },
+ "canGarden": func(garden *client.Garden, permission string) bool {
+ return garden != nil && garden.Can(permission)
+ },
+ "canUser": func(user *client.User, permission string) bool {
+ return user != nil && user.Can(permission)
+ },
+ "canGardenResource": func(garden *client.Garden, user *client.User, createdBy int, own, other string) bool {
+ if garden == nil {
+ return false
+ }
+ if user == nil {
+ return garden.Can(other)
+ }
+ if createdBy == user.ID {
+ return garden.Can(own)
+ }
+ return garden.Can(other)
+ },
+ "isAppAdmin": func(user *client.User) bool { return user != nil && user.IsAdmin() },
+ "canGlobalSpecies": func(user *client.User) bool { return user != nil && user.Can("global_species:write") },
+ "canEditSpecies": func(garden *client.Garden, user *client.User, global bool, speciesID int) bool {
+ if speciesID == 0 {
+ return garden != nil && garden.Can("species:write") || user != nil && user.Can("global_species:write")
+ }
+ if global {
+ return user != nil && user.Can("global_species:write")
+ }
+ return garden != nil && garden.Can("species:write")
+ },
+}
+
+func newTemplateCache() (map[string]*template.Template, error) {
+ cache := map[string]*template.Template{}
+
+ pages, err := fs.Glob(files, "templates/pages/*.tmpl")
+ if err != nil {
+ return nil, err
+ }
+
+ for _, page := range pages {
+ name := filepath.Base(page)
+
+ patterns := []string{
+ "templates/layout/base.tmpl",
+ "templates/partials/*.tmpl",
+ "templates/fragments/*.tmpl",
+ page,
+ }
+
+ ts, err := template.New(name).Funcs(functions).ParseFS(files, patterns...)
+ if err != nil {
+ return nil, err
+ }
+
+ cache[name] = ts
+ }
+
+ return cache, nil
+}
diff --git a/internal/web/templates/fragments/care_instructions.tmpl b/internal/web/templates/fragments/care_instructions.tmpl
new file mode 100644
index 0000000..05d42c7
--- /dev/null
+++ b/internal/web/templates/fragments/care_instructions.tmpl
@@ -0,0 +1 @@
+{{define "care_instruction_list"}}{{$editable := canEditSpecies .Garden .CurrentUser .SpeciesGlobal .SpeciesID}}
{{range .CareInstructions}}{{$instruction := .}}
{{if $editable}}{{.Text}} Status {{range careStatuses}}{{.Label}} {{end}} {{else}}{{.Text}}
{{.Status}} {{end}} {{else}}
Noch keine Pflegeanweisungen.
{{end}}
{{end}}
diff --git a/internal/web/templates/fragments/location.tmpl b/internal/web/templates/fragments/location.tmpl
new file mode 100644
index 0000000..431de1b
--- /dev/null
+++ b/internal/web/templates/fragments/location.tmpl
@@ -0,0 +1,39 @@
+{{define "location_fields"}}
+ {{$form := .Form}}
+
+
+
Name
+
+ {{with index $form.Errors "name"}}
{{.}}
{{end}}
+ {{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .Garden.ID)}}
+
Übergeordneter Ort
+
+ Keiner
+ {{range .Locations}}{{.Name}} {{end}}
+
+ {{with index $form.Errors "parent_id"}}
{{.}}
{{end}}
+
Art des Orts
+
+
Fläche in m²
+
+ {{with index $form.Errors "area_sqm"}}
{{.}}
{{end}}
+
Licht
+
Nicht angegeben Sonnig Halbschattig Schattig
+
Bodenbeschaffenheit Nicht angegeben Trocken Feucht Sumpfig
+
Bodenreaktion Nicht angegeben Basisch Sauer Neutral
+
Beschreibung
+
{{$form.Description}}
+{{end}}
+
+{{define "location_dialog"}}
+
+
+ Ort direkt anlegen
+ {{template "location_fields" .}}
+ Anlegen und auswählen Abbrechen
+
+
+{{end}}
+
+{{define "location_option"}}{{range .Locations}}
{{.Name}} {{end}}{{end}}
diff --git a/internal/web/templates/fragments/plant_tasks.tmpl b/internal/web/templates/fragments/plant_tasks.tmpl
new file mode 100644
index 0000000..1560a58
--- /dev/null
+++ b/internal/web/templates/fragments/plant_tasks.tmpl
@@ -0,0 +1,17 @@
+{{define "plant_template_tasks"}}
{{if .SpeciesID}}{{range index .TaskTemplates .SpeciesID}}
{{.Title}}
{{else}}
Für diese Art sind keine Aufgabenvorlagen hinterlegt.
{{end}}{{else}}
Nach Auswahl einer Art erscheinen hier deren Aufgabenvorlagen.
{{end}}
{{end}}
+
+{{define "plant_task_row_fragment"}}{{range .PlantTasks}}
+
+
+
+ {{.Title}} ×
+
{{end}}{{end}}
+
+{{define "plant_task_dialog"}}{{$form := .Form}}
+
+ {{if .TaskDialogNew}}Aufgabe hinzufügen{{else}}Aufgabe bearbeiten{{end}}
+ {{template "task_fields" (dict "Form" $form "Prefix" "plant_task_" "ShowPlant" true "LockPlant" true "PendingPlantName" .PendingPlantName "Plants" .Plants "Locations" .Locations "TaskPriorities" .TaskPriorities "TagSuggestions" .TagSuggestions)}}
+ Übernehmen Abbrechen
+ {{end}}
+
+{{define "plant_template_task_dialog"}}{{range index .TaskTemplates .SpeciesID}}
Aufgabenvorlage{{if and .Origin (not (eqString .Origin "manual"))}} · automatisch{{end}}
{{.Title}} {{with .Description}}{{.}}
{{else}}Keine Beschreibung hinterlegt.
{{end}}Typ {{if eqString .TriggerType "month_of_year"}}Datum{{else if eqString .TriggerType "relative_to_planting"}}Nach Pflanzdatum{{else if eqString .TriggerType "relative_to_species_planting"}}Nach Pflanzsaison{{else if eqString .TriggerType "relative_to_sowing"}}Nach Aussaat{{else if eqString .TriggerType "relative_to_harvest"}}Nach Ernte{{else}}Nach letzter Erledigung{{end}} {{if eqString .TriggerType "month_of_year"}}Ab {{with .DayFrom}}{{.}}.{{end}}{{with .MonthFrom}}{{.}}{{end}} {{else}}Nach {{durationDescription .TriggerOffset .TriggerOffsetUnit}} {{end}}Bis in {{durationDescription .Duration .DurationUnit}} Priorität {{configuredPriorityName $.TaskPriorities .Priority}} Wiederholung {{with recurrenceDescription .Recurrence .RecurrenceInterval}}{{.}}{{else}}Keine{{end}} Schließen
{{end}}{{end}}
diff --git a/internal/web/templates/fragments/task_template.tmpl b/internal/web/templates/fragments/task_template.tmpl
new file mode 100644
index 0000000..ac9618f
--- /dev/null
+++ b/internal/web/templates/fragments/task_template.tmpl
@@ -0,0 +1,25 @@
+{{define "task_template_fields"}}
+{{$form := .Form}}
+
+
Name {{with index $form.Errors "title"}}
{{.}}
{{end}}
+
Beschreibung {{$form.Description}}
+{{if and $form.Origin (not (eqString $form.Origin "manual"))}}
Automatisch aus den Saisonangaben der Art abgeleitet.
{{end}}
+
Typ Datum Nach Pflanzdatum Nach Pflanzsaison Nach Ernte Nach Aussaat
+
Ab Tag MonatBitte wählen {{range months}}{{.Label}} {{end}}
{{with index $form.Errors "date"}}{{.}}
{{end}}
+
Nach Dauer EinheitTage Wochen Monate
+
Bis in Dauer EinheitTage Wochen Monate
{{with index $form.Errors "duration"}}{{.}}
{{end}}
+
Wiederholung Alle EinheitKeine Wiederholung Tage Wochen Monate Jahre
Die nächste Aufgabe wird erst beim Erledigen angelegt.
{{with index $form.Errors "recurrence_interval"}}{{.}}
{{end}}
+
Priorität {{range .TaskPriorities}}{{.Name}} {{end}}
+
Aktiv
+{{end}}
+
+{{define "task_template_dialog"}}
+{{$species := index .Species 0}}
+
+ {{$action := pathWithQuery (webPath "task-template.new" .Garden.ID) "species_id" .SpeciesID}}{{if .TemplateID}}{{$action = pathWithQuery (webPath "task-template.edit" .Garden.ID .TemplateID) "species_id" .SpeciesID}}{{end}}
+ {{$species.CommonName}}
{{if .TemplateID}}Aufgabe bearbeiten{{else}}Aufgabe anlegen{{end}}
+ {{template "task_template_fields" .}}
+ Speichern Abbrechen
+
+
+{{end}}
diff --git a/internal/web/templates/layout/base.tmpl b/internal/web/templates/layout/base.tmpl
new file mode 100644
index 0000000..8f33a6f
--- /dev/null
+++ b/internal/web/templates/layout/base.tmpl
@@ -0,0 +1,32 @@
+{{define "base"}}
+
+
+
+
+
+
+
{{template "title" .}} - Gardomatic
+
+
+ {{if .UseJournalEditor}}
{{end}}
+
+
+
+
+
Zum Inhalt springen
+ {{template "header" .}}
+ {{template "nav" .}}
+
+ {{with .Flash}}
+ {{.}}
+ {{end}}
+ {{template "main" .}}
+
+ {{template "footer" .}}
+
+
+ {{if .UseJournalEditor}}{{end}}
+
+
+
+{{end}}
diff --git a/internal/web/templates/pages/account.tmpl b/internal/web/templates/pages/account.tmpl
new file mode 100644
index 0000000..cfc845f
--- /dev/null
+++ b/internal/web/templates/pages/account.tmpl
@@ -0,0 +1,8 @@
+{{define "title"}}Benutzerkonto{{end}}
+{{define "main"}}
+
{{$form:=.Form}}{{with $form.Message}}
{{.}}
{{end}}
+
+
+
+
Aktive Sitzungen {{if .AccountSessions}}{{range .AccountSessions}}{{$session := .}}{{if .Current}}Dieses Gerät{{else}}Weitere Sitzung{{end}} Angemeldet: {{humanDate .CreatedAt}} · gültig bis {{humanDate .ExpiresAt}}
Widerrufen {{end}}{{else}}Keine aktiven Sitzungen gefunden.
{{end}}
+{{end}}
diff --git a/internal/web/templates/pages/activate.tmpl b/internal/web/templates/pages/activate.tmpl
new file mode 100644
index 0000000..28c5405
--- /dev/null
+++ b/internal/web/templates/pages/activate.tmpl
@@ -0,0 +1,28 @@
+{{define "title"}}Account aktivieren{{end}}
+
+{{define "main"}}
+
+{{end}}
diff --git a/internal/web/templates/pages/admin.tmpl b/internal/web/templates/pages/admin.tmpl
new file mode 100644
index 0000000..35f6431
--- /dev/null
+++ b/internal/web/templates/pages/admin.tmpl
@@ -0,0 +1,120 @@
+{{define "title"}}Administration{{end}}
+{{define "main"}}
+
Administration
Anwendung verwalten
+
+
+{{template "settings_nav" (dict "Kind" "admin")}}
+
+
+{{with .ApplicationSettings}}
+
+{{end}}
+
+
+
+
+ Umgebungsvariablen
+ Wirksame Konfigurationswerte der API und Webanwendung. Sensible Werte werden ausschließlich maskiert angezeigt.
+
+ {{range .EnvironmentVariables}}
+
+ {{.Component}}
+ {{.Name}}
+ {{if .Value}}{{.Value}}{{else}}(leer){{end}}
+
+ {{else}}
Keine Umgebungsvariablen verfügbar.
{{end}}
+
+
+
+
+
+{{range .RoleEditors}}{{template "role_editor" .}}{{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/internal/web/templates/pages/dashboard.tmpl b/internal/web/templates/pages/dashboard.tmpl
new file mode 100644
index 0000000..2b3d6c0
--- /dev/null
+++ b/internal/web/templates/pages/dashboard.tmpl
@@ -0,0 +1,7 @@
+{{define "title"}}Übersicht{{end}}
+{{define "main"}}
+
{{.Garden.Name}} Kalender {{if or (canGarden .Garden "garden:update") (canGarden .Garden "members:write") (canGarden .Garden "garden:delete")}}
Bearbeiten {{end}}{{template "view_toggle" "dashboard"}}
+{{if or .Garden.ImageData .Garden.Description}}
{{with .Garden.Description}}{{end}} {{end}}
+
+
{{range .Tasks}}{{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}
{{.Title}} {{taskDueDate .}} {{with .Description}}{{.}}
{{end}} {{else}}In den nächsten 30 Tagen stehen keine Aufgaben an.
{{end}}
+{{end}}
diff --git a/internal/web/templates/pages/email_confirm.tmpl b/internal/web/templates/pages/email_confirm.tmpl
new file mode 100644
index 0000000..8c6ce23
--- /dev/null
+++ b/internal/web/templates/pages/email_confirm.tmpl
@@ -0,0 +1 @@
+{{define "title"}}E-Mail bestätigen{{end}}{{define "main"}}
Benutzerkonto
Neue E-Mail-Adresse bestätigen E-Mail-Adresse übernehmen {{end}}
diff --git a/internal/web/templates/pages/error.tmpl b/internal/web/templates/pages/error.tmpl
new file mode 100644
index 0000000..e78315d
--- /dev/null
+++ b/internal/web/templates/pages/error.tmpl
@@ -0,0 +1,9 @@
+{{define "title"}}{{.ErrorTitle}}{{end}}
+{{define "main"}}
+
+ Fehler {{.ErrorStatus}}
+ {{.ErrorTitle}}
+ {{.ErrorMessage}}
+
+
+{{end}}
diff --git a/internal/web/templates/pages/garden_form.tmpl b/internal/web/templates/pages/garden_form.tmpl
new file mode 100644
index 0000000..4e4a7da
--- /dev/null
+++ b/internal/web/templates/pages/garden_form.tmpl
@@ -0,0 +1,56 @@
+{{define "title"}}Garten bearbeiten{{end}}
+{{define "main"}}
+{{$garden := .Garden}}{{$current := .CurrentUser}}{{$forms := .Form}}
+
+
+
+
+{{if canGarden $garden "garden:update"}}
+
+Allgemein
+{{$form := $forms.Garden}}
+{{template "garden_fields" (dict "Form" $form "CSRFToken" .CSRFToken "Images" $.Images "GardenID" $garden.ID)}}
+
+{{end}}
+
+
+Mitglieder
+{{range .Members}}{{$member := .}}
+{{.Name}} {{.Email}} · {{.Role}}
+{{if and (canGarden $garden "members:write") (neInt .UserID $current.ID) (neString .Role "owner")}}
+
+
{{range $.GardenRoles}}{{if neString .Name "owner"}}{{.Label}} {{end}}{{end}} Rolle speichern
+Entfernen
+{{if and (canGarden $garden "garden:delete") (neInt .UserID $current.ID)}}Eigentum übertragen {{end}}
+{{end}}
+ {{else}}Keine Mitglieder vorhanden.
{{end}}
+
+
+{{if canGarden $garden "members:write"}}
+
+
Offene Einladungen {{range .Invites}}{{.Email}} · {{.Role}} · bis {{humanDate .ExpiresAt}}
Widerrufen {{else}}Keine offenen Einladungen.
{{end}}
+{{end}}
+
+{{if canGarden $garden "garden:delete"}}{{range .RoleEditors}}{{template "role_editor" .}}{{end}}{{end}}
+
+{{if canGarden $garden "garden:delete"}}
+
+Garten löschen Der Garten und alle zugehörigen Inhalte werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
+Garten löschen
+
+
+
+
+„{{$garden.Name}}“ wirklich löschen?
+Alle Pflanzen, Orte, Aufgaben, Tagebucheinträge und Mitgliedschaften dieses Gartens werden unwiderruflich gelöscht.
+Abbrechen Endgültig löschen
+
+
+{{end}}
+
+{{end}}
diff --git a/internal/web/templates/pages/garden_new.tmpl b/internal/web/templates/pages/garden_new.tmpl
new file mode 100644
index 0000000..87af9a5
--- /dev/null
+++ b/internal/web/templates/pages/garden_new.tmpl
@@ -0,0 +1,16 @@
+{{define "title"}}Garten anlegen{{end}}
+
+{{define "main"}}
+
+ Garten
+ Garten anlegen
+ {{$form := .Form}}
+
+ {{template "garden_fields" (dict "Form" $form "CSRFToken" .CSRFToken "Images" .Images "GardenID" 0 "Autofocus" true)}}
+
+
+
+{{end}}
diff --git a/internal/web/templates/pages/gardens.tmpl b/internal/web/templates/pages/gardens.tmpl
new file mode 100644
index 0000000..c319a69
--- /dev/null
+++ b/internal/web/templates/pages/gardens.tmpl
@@ -0,0 +1,22 @@
+{{define "title"}}Gärten{{end}}
+
+{{define "main"}}
+
+
+
Übersicht
+
Deine Gärten
+
+ {{if canUser .CurrentUser "gardens:create"}}
Garten anlegen {{end}}{{template "view_toggle" "gardens"}}
+
+
+
Suche
Rolle Alle Eigentümer Administration Mitglied Nur Lesen
Sortierung Name A–Z Name Z–A Neueste zuerst Älteste zuerst
Filtern
+
+
+ {{range .Gardens}}
+ {{with .Description}}
{{.}}
{{end}}
+ {{else}}
+ Noch kein Garten vorhanden.
+ {{end}}
+
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/healthcheck.tmpl b/internal/web/templates/pages/healthcheck.tmpl
new file mode 100644
index 0000000..64469c8
--- /dev/null
+++ b/internal/web/templates/pages/healthcheck.tmpl
@@ -0,0 +1,19 @@
+{{define "title"}}Systemstatus{{end}}
+{{define "main"}}
+
+
+ {{with .Health}}
+
+
Status {{.Status}}
+
Umgebung {{.SystemInfo.Environment}}
+
API-Version {{.SystemInfo.Version}}
+
Serverzeit {{.ServerTime.Local.Format "02.01.2006 15:04:05 MST"}}
+
Systemzeit {{$.SystemTime.Format "02.01.2006 15:04:05 MST"}}
+
Web-Version {{$.WebVersion}}
+
+ {{else}}
+ {{.HealthError}}
+
Systemzeit {{.SystemTime.Format "02.01.2006 15:04:05 MST"}}
Web-Version {{.WebVersion}}
+ {{end}}
+
+{{end}}
diff --git a/internal/web/templates/pages/home.tmpl b/internal/web/templates/pages/home.tmpl
new file mode 100644
index 0000000..de1ead8
--- /dev/null
+++ b/internal/web/templates/pages/home.tmpl
@@ -0,0 +1,10 @@
+{{define "title"}}Home{{end}}
+
+{{define "main"}}
+
+ Dein Garten. Klar organisiert.
+ Pflanzenwissen und Gartenarbeit an einem Ort.
+ Verwalte Gärten, Pflanzen, Orte und Aufgaben gemeinsam mit anderen.
+ Jetzt anmelden
+
+{{end}}
diff --git a/internal/web/templates/pages/images.tmpl b/internal/web/templates/pages/images.tmpl
new file mode 100644
index 0000000..720920e
--- /dev/null
+++ b/internal/web/templates/pages/images.tmpl
@@ -0,0 +1,7 @@
+{{define "title"}}Bilder{{end}}
+{{define "main"}}
+
Bilder Alle Fotos dieses Gartens an einem Ort.
+
+
{{range .Images}}{{if .FileName}}{{.FileName}} · {{end}}{{fileSize .Size}} · {{.CreatedAt.Format "02.01.2006"}} {{else}}Noch keine Bilder vorhanden.
{{end}}
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/invite.tmpl b/internal/web/templates/pages/invite.tmpl
new file mode 100644
index 0000000..359dc87
--- /dev/null
+++ b/internal/web/templates/pages/invite.tmpl
@@ -0,0 +1,2 @@
+{{define "title"}}Einladung{{end}}
+{{define "main"}}
Zusammenarbeit
Garteneinladung annehmen Die Einladung wird mit deiner angemeldeten E-Mail-Adresse abgeglichen.
Einladung annehmen {{end}}
diff --git a/internal/web/templates/pages/journal.tmpl b/internal/web/templates/pages/journal.tmpl
new file mode 100644
index 0000000..cbf9df9
--- /dev/null
+++ b/internal/web/templates/pages/journal.tmpl
@@ -0,0 +1,17 @@
+{{define "title"}}{{if eqString .ContentKind "pinboard"}}Pinnwand{{else}}Tagebuch{{end}}{{end}}
+{{define "main"}}
+{{$pinboard := eqString .ContentKind "pinboard"}}{{$path := "journal"}}{{if $pinboard}}{{$path = "pinboard"}}{{end}}
+
{{if $pinboard}}Ideen und Notizen{{else}}Gartentagebuch{{end}}
{{if $pinboard}}Pinnwand{{else}}Tagebuch{{end}} {{if canGarden .Garden "content:write"}}{{end}}
+{{$editable := canGarden .Garden "content:write"}}
+
+{{range .JournalEntries}}
+
+
+ {{with .Tags}}{{end}}
+ {{markdown .Body}}
+ {{with .Attachments}}{{end}}
+
+{{else}}
{{if $pinboard}}Noch keine Notizen. Sammle hier spontane Ideen für deinen Garten.{{else}}Noch keine Tagebucheinträge. Halte fest, was in deinem Garten passiert.{{end}}
{{end}}
+
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/journal_form.tmpl b/internal/web/templates/pages/journal_form.tmpl
new file mode 100644
index 0000000..dbb2279
--- /dev/null
+++ b/internal/web/templates/pages/journal_form.tmpl
@@ -0,0 +1,32 @@
+{{define "title"}}{{if eqString .ContentKind "pinboard"}}{{if .EntryID}}Notiz bearbeiten{{else}}Neue Notiz{{end}}{{else}}{{if .EntryID}}Tagebucheintrag bearbeiten{{else}}Neuer Tagebucheintrag{{end}}{{end}}{{end}}
+{{define "main"}}
+{{$pinboard := eqString .ContentKind "pinboard"}}{{$path := "journal"}}{{if $pinboard}}{{$path = "pinboard"}}{{end}}{{$editable := canGarden .Garden "content:write"}}
+
{{if $pinboard}}Pinnwand{{else}}Gartentagebuch{{end}}
{{if $pinboard}}{{if .EntryID}}Notiz bearbeiten{{else}}Neue Notiz{{end}}{{else}}{{if .EntryID}}Eintrag bearbeiten{{else}}Neuer Eintrag{{end}}{{end}}
+{{$form := .Form}}
+
+
+ Titel{{if $pinboard}} (optional) {{end}} {{with index $form.Errors "title"}}{{.}}
{{end}}
+ {{if $pinboard}} {{else}}Datum und Uhrzeit {{with index $form.Errors "date_time"}}{{.}}
{{end}}{{end}}
+ Text
+
{{$form.Body}} {{with index $form.Errors "body"}}{{.}}
{{end}}
+ Tags Mit Enter oder Komma übernehmen. Bereits verwendete Tags werden vorgeschlagen.
+ {{with $form.Attachments}}Vorhandene Anhänge {{end}}
+ {{if $pinboard}}Fotos{{else}}Fotos, Videos und Audio{{end}}
+ {{if $pinboard}}Fotos auswählen{{else}}Mediendateien auswählen{{end}}
+ {{template "journal_photo_editor"}}
+ {{if .Images}}Aus Bilderdatenbank {{end}}
+ {{if not $pinboard}}Video aufnehmen
+ Audio aufnehmen {{end}}
+
+
+
+
+ {{if .Images}}Bilder aus der Bilderdatenbank auswählen Es können mehrere Bilder ausgewählt werden.
Auswahl übernehmen
{{end}}
+
+
+{{if not $pinboard}}
+
Video aufnehmen
Kamera wechseln Aufnahme starten Pause Übernehmen Abbrechen
+
Audio aufnehmen
Bereit zur Aufnahme.
Aufnahme starten Pause Übernehmen Abbrechen
+{{end}}
+{{if and .EntryID $editable}}
{{if $pinboard}}Notiz löschen{{else}}Eintrag löschen{{end}} {{end}}
+{{end}}
diff --git a/internal/web/templates/pages/location_form.tmpl b/internal/web/templates/pages/location_form.tmpl
new file mode 100644
index 0000000..e0429de
--- /dev/null
+++ b/internal/web/templates/pages/location_form.tmpl
@@ -0,0 +1,42 @@
+{{define "title"}}{{if .LocationID}}Ort bearbeiten{{else}}Ort anlegen{{end}}{{end}}
+
+{{define "main"}}
+{{$form := .Form}}
+{{$canSave := canGarden .Garden "locations:create"}}{{if .LocationID}}{{$canSave = canGardenResource .Garden .CurrentUser .LocationCreatedBy "locations:update:own" "locations:update:other"}}{{end}}
+
+{{if .LocationID}}{{end}}
+
+ {{if .LocationID}}Ort bearbeiten{{else}}Ort anlegen{{end}}
+
+ {{template "location_fields" .}}
+
+
+
+{{if .LocationID}}
+
+ Pflanzen an diesem Ort {{template "view_toggle" "location-plants"}}
+
+ {{range .LocationPlants}}
+ {{$canEditPlant := canGardenResource $.Garden $.CurrentUser .Plant.CreatedBy "plants:update:own" "plants:update:other"}}
{{if $canEditPlant}}{{.Plant.Name}} {{else}}{{.Plant.Name}}{{end}} Anzahl: {{.Assignment.Quantity}}{{with .Assignment.PlantedAt}} · gepflanzt am {{humanDate .}}{{end}}
{{with .Assignment.Notes}}
{{.}}
{{end}}
+ {{else}}
+
Diesem Ort sind noch keine Pflanzen zugeordnet.
+ {{end}}
+
+
+
+ Historie der letzten 3 Jahre
+ {{range .LocationHistory}}
+ {{.Year}}
+ {{range .Plants}}
+ {{$canEditPlant := canGardenResource $.Garden $.CurrentUser .Plant.CreatedBy "plants:update:own" "plants:update:other"}}
{{if $canEditPlant}}{{.Plant.Name}} {{else}}{{.Plant.Name}}{{end}} {{plantStatusName .Plant.Status}}{{with .Assignment.RemovedAt}} · entfernt am {{humanDate .}}{{end}}
{{with .Assignment.Notes}}
{{.}}
{{end}}
+ {{else}}
Keine Pflanzen in diesem Jahr.
{{end}}
+
+ {{end}}
+
+{{end}}
+
+{{end}}
diff --git a/internal/web/templates/pages/locations.tmpl b/internal/web/templates/pages/locations.tmpl
new file mode 100644
index 0000000..0070939
--- /dev/null
+++ b/internal/web/templates/pages/locations.tmpl
@@ -0,0 +1,39 @@
+{{define "title"}}Orte{{end}}
+
+{{define "location_nodes"}}
+
+{{end}}
+
+{{define "main"}}
+
+
Orte
+ {{if canGarden .Garden "locations:create"}}
Ort anlegen {{end}}{{template "view_toggle" "locations"}}
+
+
+{{if .LocationTree}}
+
+ {{template "location_nodes" .LocationTree}}
+
+ {{range .Locations}}
+
+
+ {{with .Kind}}
{{.}} {{end}}{{with .Description}}
{{.}}
{{end}}
+
+ {{end}}
+
+
+{{else}}
+
In diesem Garten wurden noch keine Orte angelegt.
+{{end}}
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/plant.tmpl b/internal/web/templates/pages/plant.tmpl
new file mode 100644
index 0000000..aa6f046
--- /dev/null
+++ b/internal/web/templates/pages/plant.tmpl
@@ -0,0 +1,42 @@
+{{define "title"}}Im Garten{{end}}
+
+{{define "main"}}
+
+
Im Garten
+ {{if canGarden .Garden "plants:create"}}
Pflanze erfassen {{end}}{{template "view_toggle" "plants"}}
+
+
+
Suche
Status Alle {{range plantStatuses}}{{.Label}} {{end}}
Art oder Sorte Alle {{range .Species}}{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}} {{end}}
Ort Alle {{range .Locations}}{{.Name}} {{end}}
Sortierung Name A–Z Name Z–A Neueste zuerst Älteste zuerst
Filtern
+
+
+ {{range .Plants}}
+ {{$plant := .}}
+
+
{{plantStatusName .Status}}
+
+
{{speciesName $.Species .SpeciesID}}
+ {{with .PlantedByName}}
Gepflanzt von {{.}}
{{end}}
+
{{range index $.PlantLocations .ID}}
{{.Quantity}} x {{index $.LocationNames .LocationID}}
{{end}}
+ {{with .Notes}}
{{.}}
{{end}}
+ {{if canGardenResource $.Garden $.CurrentUser .CreatedBy "plants:update:own" "plants:update:other"}}
+
+ {{end}}
+
+ {{else}}
+ In diesem Garten wurden noch keine Pflanzen erfasst.
+ {{end}}
+
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/plant_form.tmpl b/internal/web/templates/pages/plant_form.tmpl
new file mode 100644
index 0000000..09b306a
--- /dev/null
+++ b/internal/web/templates/pages/plant_form.tmpl
@@ -0,0 +1,89 @@
+{{define "title"}}{{if .PlantID}}Pflanze bearbeiten{{else}}Pflanze erfassen{{end}}{{end}}
+
+{{define "main"}}
+{{$canSave := canGarden .Garden "plants:create"}}{{if .PlantID}}{{$canSave = canGardenResource .Garden .CurrentUser .PlantCreatedBy "plants:update:own" "plants:update:other"}}{{end}}
+
+{{if .PlantID}}{{end}}
+
+ {{if .PlantID}}Pflanze bearbeiten{{else}}Pflanze erfassen{{end}}
+ {{$form := .Form}}
+
+
+ Name
+
+ {{with index $form.Errors "name"}}{{.}}
{{end}}
+ {{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .Garden.ID)}}
+
+ Art oder Sorte
+
+ Ohne Artzuordnung
+ {{range .Species}}{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}} {{end}}
+
+ {{with index $form.Errors "species_id"}}{{.}}
{{end}}
+
+
+ Orte und Anzahl
+ {{if not .PlantID}}{{$locationURL := pathWithQuery (webPath "location.new" .Garden.ID) "return_to" (webPath "plant.new" .Garden.ID)}}Ort direkt anlegen {{end}}
+
+ {{range $index, $assignment := $form.Assignments}}
+
+
+
+ Ort
+
+ Noch keinem Ort zuordnen
+ {{range $.Locations}}{{.Name}} {{end}}
+
+
+
+
Anzahl
+
+ {{with index $form.Errors (printf "quantity_%d" $index)}}
{{.}}
{{end}}
+
+
×
+
+ {{end}}
+
+
+
+
+
+
+
+
Ort Noch keinem Ort zuordnen {{range .Locations}}{{.Name}} {{end}}
+
Anzahl
+
×
+
+
+ {{range .Locations}} {{end}}
+
+ Erworben oder gepflanzt am
+
+ {{with index $form.Errors "acquired_at"}}{{.}}
{{end}}
+
+ Status
+
+ {{range plantStatuses}}{{.Label}} {{end}}
+
+
+ {{template "tag_editor" .}}
+ Notizen
+ {{$form.Notes}}
+ {{with index $form.Errors "notes"}}{{.}}
{{end}}
+
+
+ Aufgaben
+ {{template "plant_template_tasks" .}}
+
+ {{range $form.Tasks}}{{template "plant_task_row_fragment" (plantTaskData $.Garden .)}}{{end}}
+
+
+
+
+
+
+
+ {{if and .PlantID (canGardenResource .Garden .CurrentUser .PlantCreatedBy "plants:delete:own" "plants:delete:other")}}Pflanze löschen {{end}}
+
+
+{{end}}
diff --git a/internal/web/templates/pages/plant_location_form.tmpl b/internal/web/templates/pages/plant_location_form.tmpl
new file mode 100644
index 0000000..99db235
--- /dev/null
+++ b/internal/web/templates/pages/plant_location_form.tmpl
@@ -0,0 +1,2 @@
+{{define "title"}}{{if .LocationID}}Zuordnung bearbeiten{{else}}Ort zuordnen{{end}}{{end}}
+{{define "main"}}{{$editable := canGarden .Garden "content:write"}}
{{end}}
diff --git a/internal/web/templates/pages/privacy.tmpl b/internal/web/templates/pages/privacy.tmpl
new file mode 100644
index 0000000..336cf21
--- /dev/null
+++ b/internal/web/templates/pages/privacy.tmpl
@@ -0,0 +1,15 @@
+{{define "title"}}Datenschutz{{end}}
+{{define "main"}}
+
+
+ Diese Gardomatic-Instanz verarbeitet nur Daten, die für Benutzerkonten und die gemeinsame Gartenverwaltung benötigt werden.
+ Verarbeitete Daten
+ Dazu gehören Kontaktdaten des Benutzerkontos, Sitzungsdaten sowie die von Nutzern eingetragenen Garten-, Pflanzen-, Aufgaben-, Tagebuch- und Mediendaten. Serverprotokolle können außerdem technische Verbindungsdaten enthalten.
+ Zweck und Weitergabe
+ Die Daten werden zur Bereitstellung und Absicherung der Anwendung verarbeitet. Eine Weitergabe erfolgt nur an technisch notwendige Dienstleister des Instanzbetreibers oder wenn eine gesetzliche Verpflichtung besteht.
+ Speicherdauer und Rechte
+ Daten werden gelöscht oder anonymisiert, sobald sie für den Betrieb nicht mehr erforderlich sind. Betroffene Personen können beim Betreiber dieser Instanz Auskunft, Berichtigung, Löschung oder Einschränkung der Verarbeitung anfragen.
+ Verantwortlicher
+ Verantwortlich ist der Betreiber dieser Gardomatic-Instanz. Die konkreten Kontakt- und Hostingangaben sind vom Betreiber vor dem öffentlichen Einsatz zu ergänzen.
+
+{{end}}
diff --git a/internal/web/templates/pages/search.tmpl b/internal/web/templates/pages/search.tmpl
new file mode 100644
index 0000000..56d0fb8
--- /dev/null
+++ b/internal/web/templates/pages/search.tmpl
@@ -0,0 +1,16 @@
+{{define "title"}}Suche{{end}}
+{{define "main"}}
+
Suche {{template "view_toggle" "search"}}
+
Freitextsuche Suchen
Monat Alle Monate {{range months}}{{.Label}} {{end}}Jahr Alle Jahre {{range .Search.Years}}{{.}} {{end}}Suchen
Monat und Jahr beziehen sich bei Aufgaben auf das Start- oder Enddatum.
+{{if or .Search.Query .Search.Month .Search.Year}}
+
Alle {{len .Search.All}} Tagebuch {{len .Search.Journal}} Pinnwand {{len .Search.Pinboard}} Im Garten {{len .Search.Plants}} Aufgaben {{len .Search.Tasks}} Tags {{len .Search.Tags}} Pflanzen {{len .Search.Species}}
+
Alle Treffer {{template "search_cards" .Search.All}}
+
Tagebuch {{template "search_cards" .Search.Journal}}
+
Pinnwand {{template "search_cards" .Search.Pinboard}}
+
Im Garten {{template "search_cards" .Search.Plants}}
+
Aufgaben {{template "search_cards" .Search.Tasks}}
+
+
Pflanzen und Pflanzzeiten {{template "search_cards" .Search.Species}}
+{{end}}
+{{end}}
+{{define "search_cards"}}
{{end}}
diff --git a/internal/web/templates/pages/settings.tmpl b/internal/web/templates/pages/settings.tmpl
new file mode 100644
index 0000000..75994f3
--- /dev/null
+++ b/internal/web/templates/pages/settings.tmpl
@@ -0,0 +1,37 @@
+{{define "title"}}Einstellungen{{end}}
+{{define "main"}}
+
+
+{{template "settings_nav" (dict "Kind" "user")}}
+
+
+ Seitengröße
+ Lege fest, wie viele Einträge auf Listen- und Übersichtsseiten erscheinen.
+ Einträge pro Seite
+ 10 20 50 100
+
+
+ Ansichten
+ Standard für alle Seiten
+ Kacheln Liste
+ Abweichungen je Seite
+ Gartenübersicht Standard verwenden Kacheln Liste
+ Gärten Standard verwenden Kacheln Liste
+ Im Garten Standard verwenden Kacheln Liste
+ Pflanzen Standard verwenden Kacheln Liste
+ Orte Standard verwenden Kacheln Liste
+ Aufgaben Standard verwenden Kacheln Liste
+ Pflanzen an einem Ort Standard verwenden Kacheln Liste
+ Suchergebnisse Standard verwenden Kacheln Liste
+
+
+ Einstellungen speichern
+
+
+ Texteingabe
+ Wähle, wie Texte im Tagebuch und an der Pinnwand bearbeitet werden.
+ Editor
+ Formatierter Editor Einfaches Textfeld
+
+
+
{{end}}
diff --git a/internal/web/templates/pages/signin.tmpl b/internal/web/templates/pages/signin.tmpl
new file mode 100644
index 0000000..8777168
--- /dev/null
+++ b/internal/web/templates/pages/signin.tmpl
@@ -0,0 +1,23 @@
+{{define "title"}}Anmelden{{end}}
+
+{{define "main"}}
+
+{{end}}
diff --git a/internal/web/templates/pages/species.tmpl b/internal/web/templates/pages/species.tmpl
new file mode 100644
index 0000000..c4b8bf2
--- /dev/null
+++ b/internal/web/templates/pages/species.tmpl
@@ -0,0 +1,18 @@
+{{define "title"}}Pflanzen{{end}}
+{{define "main"}}
+
+
Pflanzen
+ {{if or (canGarden .Garden "species:write") (canGlobalSpecies .CurrentUser)}}
Art anlegen {{end}}{{template "view_toggle" "species"}}
+
+
Suche
Herkunft Alle Garteneigen Global
Sortierung Name A–Z Name Z–A Neueste zuerst Älteste zuerst
Filtern
+
+ {{range .Species}}
+
+
{{if .GardenID}}Garteneigen{{else}}Global{{end}}
+
{{.CommonName}}{{with .Cultivar}} · {{.}}{{end}}
+ {{with .BotanicalName}}
{{.}}
{{end}}
+
+ {{else}}Keine Arten verfügbar.
{{end}}
+
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/pages/species_form.tmpl b/internal/web/templates/pages/species_form.tmpl
new file mode 100644
index 0000000..fd25cb9
--- /dev/null
+++ b/internal/web/templates/pages/species_form.tmpl
@@ -0,0 +1,72 @@
+{{define "title"}}{{if .SpeciesID}}Art ansehen{{else}}Art anlegen{{end}}{{end}}
+
+{{define "main"}}
+{{$form := .Form}}
+{{$editable := canEditSpecies .Garden .CurrentUser $form.Global .SpeciesID}}
+{{$step := .WizardStep}}
+{{$all := eqString $step "all"}}
+{{$general := or $all (eqString $step "") (eqString $step "general")}}
+
+{{if .SpeciesID}}{{end}}
+
+{{if $general}}
+
+{{end}}
+
+{{if and .SpeciesID (or $all (eqString $step "care"))}}
+
+ Pflegeanweisungen
+ {{template "care_instruction_list" .}}
+ {{if $editable}}Text Status{{range careStatuses}}{{.Label}} {{end}} Hinzufügen {{end}}
+ {{if not $all}}{{end}}
+
+
{{end}}
+{{if and .SpeciesID (or $all (eqString $step "tasks"))}}
+
+ Aufgabenvorlagen
+
+ {{range index .TaskTemplates .SpeciesID}}
+
+ {{if $editable}}
+ {{.Title}}{{if and .Origin (not (eqString .Origin "manual"))}} · automatisch{{end}}{{if not .Active}} · inaktiv{{end}}
+ {{if or (not .Origin) (eqString .Origin "manual")}}
× {{end}}
+ {{else}}{{.Title}} {{end}}
+
+ {{else}}
Noch keine Vorlagen.
{{end}}
+
+ {{if $editable}} {{end}}
+
+ {{if not $all}}{{end}}
+
+
{{end}}
+
+{{end}}
diff --git a/internal/web/templates/pages/task_calendar.tmpl b/internal/web/templates/pages/task_calendar.tmpl
new file mode 100644
index 0000000..1bdb864
--- /dev/null
+++ b/internal/web/templates/pages/task_calendar.tmpl
@@ -0,0 +1,6 @@
+{{define "title"}}Aufgabenkalender{{end}}
+{{define "main"}}
+
Anstehende Aufgaben {{calendarDate .CalendarStart}} bis {{calendarDate .CalendarEnd}}
+
Woche Monat
+
{{range .CalendarDays}}{{calendarDate .Date}} {{range .Tasks}}{{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}{{if $editable}}{{.Title}} {{taskDue .}} {{else}}{{.Title}} {{taskDue .}}
{{end}}{{else}}Keine Aufgaben {{end}} {{end}}
+{{end}}
diff --git a/internal/web/templates/pages/task_form.tmpl b/internal/web/templates/pages/task_form.tmpl
new file mode 100644
index 0000000..2e520eb
--- /dev/null
+++ b/internal/web/templates/pages/task_form.tmpl
@@ -0,0 +1,13 @@
+{{define "title"}}{{if .TaskID}}Aufgabe bearbeiten{{else}}Aufgabe anlegen{{end}}{{end}}
+{{define "main"}}
+{{$canSave := canGarden .Garden "tasks:create"}}{{if .TaskID}}{{$canSave = canGardenResource .Garden .CurrentUser .TaskCreatedBy "tasks:update:own" "tasks:update:other"}}{{end}}
+
+{{if .TaskID}}Aufgabe bearbeiten{{else}}Neue Aufgabe{{end}}
+
+
+ {{template "task_fields" (dict "Form" .Form "Prefix" "" "ShowPlant" true "Plants" .Plants "Locations" .Locations "TaskPriorities" .TaskPriorities "TagSuggestions" .TagSuggestions)}}
+
+
+{{if and .TaskID (canGardenResource .Garden .CurrentUser .TaskCreatedBy "tasks:delete:own" "tasks:delete:other")}}Aufgabe löschen {{end}}
+
+{{end}}
diff --git a/internal/web/templates/pages/task_template_form.tmpl b/internal/web/templates/pages/task_template_form.tmpl
new file mode 100644
index 0000000..315105e
--- /dev/null
+++ b/internal/web/templates/pages/task_template_form.tmpl
@@ -0,0 +1,11 @@
+{{define "title"}}Aufgabenvorlage{{end}}
+
+{{define "main"}}
+
{{$species := index .Species 0}}
+ {{$species.CommonName}}
{{if .TemplateID}}Vorlage bearbeiten{{else}}Vorlage anlegen{{end}}
+
+ {{template "task_template_fields" .}}
+
+
+
+{{end}}
diff --git a/internal/web/templates/pages/tasks.tmpl b/internal/web/templates/pages/tasks.tmpl
new file mode 100644
index 0000000..5bd250f
--- /dev/null
+++ b/internal/web/templates/pages/tasks.tmpl
@@ -0,0 +1,32 @@
+{{define "title"}}Aufgaben{{end}}
+{{define "main"}}
+
+
Status Alle Offen Erledigt
Pflanze Alle {{range .Plants}}{{.Name}} {{end}}
Ort Alle {{range .Locations}}{{.Name}} {{end}}
Priorität Alle {{range .TaskPriorities}}{{.Name}} {{end}}
Monat Alle {{range months}}{{.Label}} {{end}}
Jahr Alle {{range .TaskYears}}{{.}} {{end}}
Sortierung Fälligkeit aufsteigend Fälligkeit absteigend Monat aufsteigend Monat absteigend Zeitraum kurz–lang Zeitraum lang–kurz Name A–Z Name Z–A Priorität
Filtern
+{{if .Tasks}}
+
+{{range .Tasks}}
+ {{$editable := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:update:own" "tasks:update:other"}}
+ {{$canComplete := canGardenResource $.Garden $.CurrentUser .CreatedBy "tasks:complete:own" "tasks:complete:other"}}
+
+ {{configuredPriorityName $.TaskPriorities .Priority}}
{{.Title}} {{taskDueDate .}}
+ {{with .Description}}{{.}}
{{end}}
+ {{with recurrenceDescription .Recurrence .RecurrenceInterval}}Wiederholung: {{.}}
{{end}}
+
+
+{{end}}
+
+{{else}}
In diesem Garten gibt es noch keine Aufgaben.
{{end}}
+{{template "pagination" .}}
+{{end}}
diff --git a/internal/web/templates/partials/aside.tmpl b/internal/web/templates/partials/aside.tmpl
new file mode 100644
index 0000000..7e1dd78
--- /dev/null
+++ b/internal/web/templates/partials/aside.tmpl
@@ -0,0 +1,8 @@
+{{define "aside"}}
+
+{{with .Garden}}
+ {{.Name}}
+ {{with .Description}}{{.}}
{{end}}
+{{end}}
+
+{{end}}
diff --git a/internal/web/templates/partials/footer.tmpl b/internal/web/templates/partials/footer.tmpl
new file mode 100644
index 0000000..6e9aa81
--- /dev/null
+++ b/internal/web/templates/partials/footer.tmpl
@@ -0,0 +1,11 @@
+{{define "footer"}}
+
+{{end}}
diff --git a/internal/web/templates/partials/garden_fields.tmpl b/internal/web/templates/partials/garden_fields.tmpl
new file mode 100644
index 0000000..27026d3
--- /dev/null
+++ b/internal/web/templates/partials/garden_fields.tmpl
@@ -0,0 +1,11 @@
+{{define "garden_fields"}}
+{{$form := .Form}}
+
+
Name
+
+{{with index $form.Errors "name"}}
{{.}}
{{end}}
+
Beschreibung
+
{{$form.Description}}
+{{with index $form.Errors "description"}}
{{.}}
{{end}}
+{{template "image_editor" (dict "ImageData" $form.ImageData "ImageID" $form.ImageID "Images" .Images "GardenID" .GardenID)}}
+{{end}}
diff --git a/internal/web/templates/partials/header.tmpl b/internal/web/templates/partials/header.tmpl
new file mode 100644
index 0000000..88e9d23
--- /dev/null
+++ b/internal/web/templates/partials/header.tmpl
@@ -0,0 +1,23 @@
+{{define "header"}}
+
+{{end}}
diff --git a/internal/web/templates/partials/image_editor.tmpl b/internal/web/templates/partials/image_editor.tmpl
new file mode 100644
index 0000000..f6eb3c5
--- /dev/null
+++ b/internal/web/templates/partials/image_editor.tmpl
@@ -0,0 +1,37 @@
+{{define "image_editor"}}
+
+ Bild
+
+
+ Noch kein Bild gewählt
+
+ Bild hochladen
+ Foto aufnehmen
+ {{if .Images}}Aus Bilderdatenbank {{end}}
+ Bild entfernen
+
+ {{if .Images}}Bild aus der Bilderdatenbank auswählen {{range .Images}}
{{if .FileName}}{{.FileName}}{{else}}{{.CreatedAt.Format "02.01.2006"}}{{end}} {{end}}
Abbrechen
{{end}}
+
+ Bild zuschneiden
+
+
+ ↶ Drehen
+ ↷ Drehen
+ Übernehmen
+ Abbrechen
+
+
+
+
+ Foto aufnehmen
+
+
+
+ Kamera wechseln
+ Foto aufnehmen
+ Abbrechen
+
+
+ Das Bild wird im Verhältnis 3:2 zugeschnitten und für die Kacheldarstellung optimiert.
+
+{{end}}
diff --git a/internal/web/templates/partials/journal_photo_editor.tmpl b/internal/web/templates/partials/journal_photo_editor.tmpl
new file mode 100644
index 0000000..9700b9c
--- /dev/null
+++ b/internal/web/templates/partials/journal_photo_editor.tmpl
@@ -0,0 +1,8 @@
+{{define "journal_photo_editor"}}
+
+
+
Foto mit Gerät aufnehmen
+
Foto zuschneiden ↶ Drehen ↷ Drehen Übernehmen Abbrechen
+
Foto aufnehmen
Kamera wechseln Foto aufnehmen Abbrechen
+
+{{end}}
diff --git a/internal/web/templates/partials/nav.tmpl b/internal/web/templates/partials/nav.tmpl
new file mode 100644
index 0000000..29a8dbf
--- /dev/null
+++ b/internal/web/templates/partials/nav.tmpl
@@ -0,0 +1,27 @@
+{{define "nav"}}
+{{if .IsAuthenticated}}
+ {{if .IsActivated}}
+ {{with .Garden}}
+
+ Übersicht
+ Im Garten
+ Pflanzen
+ Orte
+ Aufgaben
+ Tagebuch
+ Pinnwand
+ Bilder
+
+
+ {{end}}
+ {{else}}
+
+ Account aktivieren
+
+ {{end}}
+{{else}}
+
+ Anmelden
+
+{{end}}
+{{end}}
diff --git a/internal/web/templates/partials/pagination.tmpl b/internal/web/templates/partials/pagination.tmpl
new file mode 100644
index 0000000..28b0a5b
--- /dev/null
+++ b/internal/web/templates/partials/pagination.tmpl
@@ -0,0 +1 @@
+{{define "pagination"}}{{with .Pagination}}{{end}}{{end}}
diff --git a/internal/web/templates/partials/role_editor.tmpl b/internal/web/templates/partials/role_editor.tmpl
new file mode 100644
index 0000000..ee2152f
--- /dev/null
+++ b/internal/web/templates/partials/role_editor.tmpl
@@ -0,0 +1,37 @@
+{{define "role_editor"}}
+
+{{end}}
diff --git a/internal/web/templates/partials/season_range.tmpl b/internal/web/templates/partials/season_range.tmpl
new file mode 100644
index 0000000..5d1d63d
--- /dev/null
+++ b/internal/web/templates/partials/season_range.tmpl
@@ -0,0 +1,8 @@
+{{define "season_range"}}
+
{{.Legend}}
+ Monat vonNicht angegeben {{range months}}{{.Label}} {{end}}
+ Tag von
+ Zeitraum
+ EinheitTage Wochen Monate
+
+{{end}}
diff --git a/internal/web/templates/partials/settings_nav.tmpl b/internal/web/templates/partials/settings_nav.tmpl
new file mode 100644
index 0000000..58a3509
--- /dev/null
+++ b/internal/web/templates/partials/settings_nav.tmpl
@@ -0,0 +1,19 @@
+{{define "settings_nav"}}
+
+{{end}}
diff --git a/internal/web/templates/partials/tag_editor.tmpl b/internal/web/templates/partials/tag_editor.tmpl
new file mode 100644
index 0000000..373bc52
--- /dev/null
+++ b/internal/web/templates/partials/tag_editor.tmpl
@@ -0,0 +1,9 @@
+{{define "tag_editor"}}
+
Tags
+
+
+
+
{{range .TagSuggestions}}{{.}} {{end}}
+
+
Mit Enter oder Komma übernehmen. Bereits verwendete Tags werden vorgeschlagen.
+{{end}}
diff --git a/internal/web/templates/partials/task_fields.tmpl b/internal/web/templates/partials/task_fields.tmpl
new file mode 100644
index 0000000..b0b63ab
--- /dev/null
+++ b/internal/web/templates/partials/task_fields.tmpl
@@ -0,0 +1,11 @@
+{{define "task_fields"}}{{$form:=.Form}}{{$prefix:=.Prefix}}
+
Name
+
Beschreibung{{$form.Description}}
+
Tags
+{{if .ShowPlant}}
Pflanze (optional){{if and .LockPlant .PendingPlantName}}{{.PendingPlantName}} · wird beim Speichern angelegt {{else}}Keine {{range .Plants}}{{.Name}} {{end}}{{end}} {{end}}
+
Ort (optional)Keiner {{range .Locations}}{{.Name}} {{end}}
+
Fällig ab Fällig bis
+
Wiederholung Alle EinheitKeine Tage Wochen Monate Jahre
+
Priorität{{range .TaskPriorities}}{{.Name}} {{end}}
+
Pflanzenstatus nach ErledigungNicht ändern {{range plantStatuses}}{{.Label}} {{end}}
+{{end}}
diff --git a/internal/web/templates/partials/view_toggle.tmpl b/internal/web/templates/partials/view_toggle.tmpl
new file mode 100644
index 0000000..00bddd7
--- /dev/null
+++ b/internal/web/templates/partials/view_toggle.tmpl
@@ -0,0 +1,4 @@
+{{define "view_toggle"}}
+
+
+ {{end}}
diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go
new file mode 100644
index 0000000..eb7cf2a
--- /dev/null
+++ b/internal/web/templates_test.go
@@ -0,0 +1,577 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "gardomatic.kleiax.de/lib/client"
+)
+
+func TestHumanDate(t *testing.T) {
+ tests := []struct {
+ name string
+ tm time.Time
+ want string
+ }{
+ {
+ name: "UTC",
+ tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
+ want: "17 Mar 2022 at 10:15",
+ },
+ {
+ name: "Empty",
+ tm: time.Time{},
+ want: "",
+ },
+ {
+ name: "CET",
+ tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
+ want: "17 Mar 2022 at 09:15",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hd := humanDate(tt.tm)
+
+ if hd != tt.want {
+ t.Errorf("humanDate(): got %q, want %q", hd, tt.want)
+ }
+ })
+ }
+}
+
+func TestGlobalSpeciesControlsArePermissionAware(t *testing.T) {
+ app := newTestApplication(t)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
+ for _, test := range []struct {
+ name string
+ user client.User
+ wantGlobal bool
+ wantAdmin bool
+ }{
+ {"admin", client.User{ID: 1, Activated: true, Role: "application:admin", Permissions: []string{"global_species:write", "roles:manage"}}, true, true},
+ {"user", client.User{ID: 2, Activated: true, Role: "application:user"}, false, false},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &test.user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "species_form.tmpl", data)
+ body := response.Body.String()
+ if strings.Contains(body, "name='global'") != test.wantGlobal {
+ t.Errorf("global checkbox visibility mismatch: %s", body)
+ }
+ if strings.Contains(body, "href='/admin") != test.wantAdmin {
+ t.Errorf("admin menu visibility mismatch: %s", body)
+ }
+ })
+ }
+}
+
+func TestSpeciesCardsAndReadOnlyGlobalDetails(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 2, Activated: true, Role: "application:user"}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
+ species := client.Species{ID: 7, CommonName: "Tomate", Cultivar: "Roma"}
+
+ listData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}, speciesTemplateData: speciesTemplateData{Species: []client.Species{species}}}
+ listResponse := httptest.NewRecorder()
+ app.render(listResponse, http.StatusOK, "species.tmpl", listData)
+ listBody := listResponse.Body.String()
+ if !strings.Contains(listBody, "data-card-href='/g/3/species/edit/7'") || strings.Contains(listBody, ">Bearbeiten") {
+ t.Fatalf("species card is not exclusively clickable: %s", listBody)
+ }
+
+ generalData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Global: true, CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "general", TaskTemplates: map[int][]client.SpeciesTaskTemplate{7: {{ID: 9, SpeciesID: 7, Title: "Ausgeizen"}}}}}
+ generalResponse := httptest.NewRecorder()
+ app.render(generalResponse, http.StatusOK, "species_form.tmpl", generalData)
+ if body := generalResponse.Body.String(); !strings.Contains(body, "Diese Art ist nur lesbar") || !strings.Contains(body, "disabled") || strings.Contains(body, "Art löschen") {
+ t.Fatalf("global species general details are not read-only: %s", body)
+ }
+
+ tasksData := &templateData{commonTemplateData: generalData.commonTemplateData, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "tasks", TaskTemplates: map[int][]client.SpeciesTaskTemplate{7: {{ID: 9, SpeciesID: 7, Title: "Ausgeizen"}}}}}
+ tasksResponse := httptest.NewRecorder()
+ app.render(tasksResponse, http.StatusOK, "species_form.tmpl", tasksData)
+ if body := tasksResponse.Body.String(); !strings.Contains(body, "Ausgeizen") || !strings.Contains(body, "Bearbeitungsbereiche") || strings.Contains(body, "Aufgabenvorlage hinzufügen") {
+ t.Fatalf("global species task templates are not read-only: %s", body)
+ }
+}
+
+func TestEditableSpeciesDetailOffersDelete(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Activated: true, Role: "application:admin", Permissions: []string{"global_species:write", "roles:manage"}}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
+ data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Global: true, CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "species_form.tmpl", data)
+ if body := response.Body.String(); !strings.Contains(body, "formaction='/g/3/species/delete/7'") || !strings.Contains(body, "Art löschen") || strings.Contains(body, "class='delete-form'") {
+ t.Fatalf("delete action missing from editable species: %s", body)
+ }
+}
+
+func TestGardenContextIsKeptInAccountAndSettingsLinks(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Name: "Alice", Activated: true}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "settings.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"href='/account?garden=3'", "href='/settings?garden=3'", "href='/g/3'>Hinterhof", "name='journalEditor'", "Einfaches Textfeld"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("garden-aware navigation is missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ editor := globalRoleEditor("roles-settings", "Instanzrollen", "Instanz", "application", nil, applicationPermissionOptions(), "token")
+ editor.Garden = &garden
+ data := &templateData{
+ commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden},
+ adminTemplateData: adminTemplateData{ApplicationSettings: &client.ApplicationSettings{}, RoleEditors: []roleEditorData{editor}},
+ }
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "admin.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"href='/admin?garden=3'", "action='/admin/application-settings?garden=3'", "action='/admin/user-invite?garden=3'", "action='/admin/roles/new?garden=3'"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("admin page does not retain garden context in %q: %s", want, body)
+ }
+ }
+}
+
+func TestFooterAndPublicInformationPages(t *testing.T) {
+ app := newTestApplication(t)
+ health := client.Health{Status: "available", ServerTime: time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC), SystemInfo: client.SystemInfo{Environment: "production", Version: "v1.2.3"}}
+ data := &templateData{commonTemplateData: commonTemplateData{CurrentYear: 2026, Health: &health, SystemTime: time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local), WebVersion: "v1.2.3"}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "healthcheck.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"Systemstatus", "Serverzeit", "Systemzeit", "production", "v1.2.3", "https://git.kleiax.de/kleiax/Gardomatic", "https://kleiax.de", "href='/datenschutz'"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("health page or footer is missing %q: %s", want, body)
+ }
+ }
+
+ response = httptest.NewRecorder()
+ app.render(response, http.StatusOK, "privacy.tmpl", &templateData{})
+ if body = response.Body.String(); !strings.Contains(body, "Verarbeitete Daten") || !strings.Contains(body, "Verantwortlicher") {
+ t.Fatalf("privacy page is incomplete: %s", body)
+ }
+}
+
+func TestGardenCreationControlRequiresPermission(t *testing.T) {
+ app := newTestApplication(t)
+ for _, test := range []struct {
+ name string
+ user client.User
+ want bool
+ }{
+ {"allowed", client.User{Permissions: []string{"gardens:create"}}, true},
+ {"denied", client.User{Permissions: []string{}}, false},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "gardens.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &test.user, Filters: map[string]string{}}})
+ if got := strings.Contains(response.Body.String(), "href='/gardens/new'"); got != test.want {
+ t.Fatalf("garden creation link visibility = %v, want %v", got, test.want)
+ }
+ })
+ }
+}
+
+func TestJournalFormOffersNativeCaptureAndCameraSwitching(t *testing.T) {
+ app := newTestApplication(t)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Form: journalForm{Errors: map[string]string{}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "journal_form.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"Foto mit Gerät aufnehmen", "data-journal-generated-files", "data-camera-switch", "data-video-switch-camera"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("mobile journal form is missing %q: %s", want, body)
+ }
+ }
+ if strings.Contains(body, "capture='environment'") {
+ t.Errorf("journal form still contains the redundant native camera picker: %s", body)
+ }
+}
+
+func TestServerErrorRendersFriendlyPage(t *testing.T) {
+ app := newTestApplication(t)
+ response := httptest.NewRecorder()
+ app.serverError(response, errors.New("database unavailable"))
+ if response.Code != http.StatusInternalServerError {
+ t.Fatalf("status: got %d, want %d", response.Code, http.StatusInternalServerError)
+ }
+ body := response.Body.String()
+ if !strings.Contains(body, "Etwas ist schiefgelaufen") || strings.Contains(body, "database unavailable") {
+ t.Fatalf("friendly error page missing or leaks internal details: %s", body)
+ }
+}
+
+func TestSpeciesFormUsesCategoryDropdown(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Activated: true}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{CategoryID: 2, Errors: map[string]string{}}}, adminTemplateData: adminTemplateData{SpeciesCategories: []client.SpeciesCategory{{ID: 1, Name: "Gemüse", Active: true}, {ID: 2, Name: "Alt", Active: false}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "species_form.tmpl", data)
+ body := response.Body.String()
+ if !strings.Contains(body, "
") || !strings.Contains(body, "value='2' selected>Alt (inaktiv)") || strings.Contains(body, " Speichern und weiter") || !strings.Contains(body, "Speichern und beenden") {
+ t.Fatalf("species creation does not offer the wizard: %s", body)
+ }
+
+ care := httptest.NewRecorder()
+ app.render(care, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "care", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
+ if body := care.Body.String(); !strings.Contains(body, "Pflegeanweisungen") || !strings.Contains(body, "step=tasks") || !strings.Contains(body, "Überspringen und beenden") {
+ t.Fatalf("care wizard step is incomplete: %s", body)
+ }
+
+ tasks := httptest.NewRecorder()
+ app.render(tasks, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "tasks", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
+ if body := tasks.Body.String(); !strings.Contains(body, "Aufgabenvorlagen") || !strings.Contains(body, ">Fertig") || !strings.Contains(body, "Bearbeitungsbereiche") {
+ t.Fatalf("task-template wizard step is incomplete: %s", body)
+ }
+
+ normalEdit := httptest.NewRecorder()
+ app.render(normalEdit, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "all", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
+ if body := normalEdit.Body.String(); !strings.Contains(body, "id='species-general'") || !strings.Contains(body, "id='species-care'") || !strings.Contains(body, "id='species-tasks'") || !strings.Contains(body, "href='#species-care'") || strings.Contains(body, "Weiter zu Aufgabenvorlagen") {
+ t.Fatalf("normal species edit does not load all navigable sections: %s", body)
+ }
+}
+
+func TestPlantAndLocationEditFormsHaveSectionNavigation(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Activated: true}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+
+ plant := httptest.NewRecorder()
+ app.render(plant, http.StatusOK, "plant_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: plantForm{Errors: map[string]string{}}}, plantTemplateData: plantTemplateData{PlantID: 7}, adminTemplateData: adminTemplateData{}, speciesTemplateData: speciesTemplateData{TaskTemplates: map[int][]client.SpeciesTaskTemplate{}, TemplateOptOuts: map[int]bool{}}})
+ if body := plant.Body.String(); !strings.Contains(body, "Bearbeitungsbereiche") || !strings.Contains(body, "href='#plant-general'") || !strings.Contains(body, "href='#plant-tasks'") {
+ t.Fatalf("plant edit navigation is missing: %s", body)
+ }
+
+ location := httptest.NewRecorder()
+ app.render(location, http.StatusOK, "location_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: locationForm{Errors: map[string]string{}}}, locationTemplateData: locationTemplateData{LocationID: 8}})
+ if body := location.Body.String(); !strings.Contains(body, "href='#location-general'") || !strings.Contains(body, "href='#location-plants'") || !strings.Contains(body, "href='#location-history'") {
+ t.Fatalf("location edit navigation is missing: %s", body)
+ }
+}
+
+func TestImageEditorRestoresSavedImageThroughJavaScript(t *testing.T) {
+ app := newTestApplication(t)
+ imageData := "data:image/jpeg;base64,/9j/test"
+ data := &templateData{commonTemplateData: commonTemplateData{Form: gardenForm{Name: "Garten", ImageData: imageData, Errors: map[string]string{}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "garden_new.tmpl", data)
+ body := response.Body.String()
+ if !strings.Contains(body, "name='image_data' value='"+imageData+"'") {
+ t.Fatalf("saved image data missing from editor: %s", body)
+ }
+ if strings.Contains(body, "#ZgotmplZ") || strings.Contains(body, "background-image:url") {
+ t.Fatalf("image preview must be initialized by JavaScript, not an unsafe inline URL: %s", body)
+ }
+}
+
+func TestGardenPresentationIsLimitedToHeaderAndDashboard(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Activated: true}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Description: "Sonniger Stadtgarten", ImageData: "data:image/jpeg;base64,/9j/test", Role: "owner"}
+
+ dashboardResponse := httptest.NewRecorder()
+ app.render(dashboardResponse, http.StatusOK, "dashboard.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}})
+ dashboardBody := dashboardResponse.Body.String()
+ for _, want := range []string{"class='site-header-garden' href='/g/3'>Hinterhof", "garden-summary card--image", "data-background-image='" + garden.ImageData + "'", ">Sonniger Stadtgarten", "href='/g/3/tasks/calendar'>Kalender", "href='/gardens/edit/3'>Bearbeiten"} {
+ if !strings.Contains(dashboardBody, want) {
+ t.Errorf("garden dashboard is missing %q: %s", want, dashboardBody)
+ }
+ }
+ viewerGarden := garden
+ viewerGarden.Role = "viewer"
+ viewerResponse := httptest.NewRecorder()
+ app.render(viewerResponse, http.StatusOK, "dashboard.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &viewerGarden}})
+ if strings.Contains(viewerResponse.Body.String(), "/gardens/edit/3") {
+ t.Fatalf("read-only garden dashboard contains edit link: %s", viewerResponse.Body.String())
+ }
+
+ plantsResponse := httptest.NewRecorder()
+ app.render(plantsResponse, http.StatusOK, "plant.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}})
+ plantsBody := plantsResponse.Body.String()
+ if strings.Count(plantsBody, garden.Name) != 1 || strings.Contains(plantsBody, garden.Description) {
+ t.Fatalf("garden details should only appear once in the header away from the dashboard: %s", plantsBody)
+ }
+
+ gardensResponse := httptest.NewRecorder()
+ app.render(gardensResponse, http.StatusOK, "gardens.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user}, gardenTemplateData: gardenTemplateData{Gardens: []client.Garden{garden}}})
+ if gardensBody := gardensResponse.Body.String(); strings.Contains(gardensBody, "/gardens/edit/3") {
+ t.Fatalf("garden list still contains edit link: %s", gardensBody)
+ }
+}
+
+func TestEmptyOptionalSpeciesDimensionsDoNotBlockSubmit(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Activated: true}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "species_form.tmpl", data)
+ body := response.Body.String()
+ for _, field := range []string{"spacing_cm", "height_cm"} {
+ if !strings.Contains(body, "name='"+field+"' value=''") {
+ t.Errorf("optional field %s should render empty: %s", field, body)
+ }
+ }
+}
+
+func TestTaskDueFormatsWindows(t *testing.T) {
+ start := time.Date(2026, 9, 2, 8, 0, 0, 0, time.Local)
+ end := time.Date(2026, 9, 3, 18, 0, 0, 0, time.Local)
+ if got := taskDue(client.Task{DueAtStart: &start, DueAtEnd: &end}); got != "02.09.2026 08:00 - 03.09.2026 18:00" {
+ t.Errorf("taskDue: %q", got)
+ }
+ if got := taskDue(client.Task{}); got != "Ohne Fälligkeit" {
+ t.Errorf("taskDue empty: %q", got)
+ }
+ if got := taskDueDate(client.Task{DueAtStart: &start, DueAtEnd: &end}); got != "02.09.2026 - 03.09.2026" {
+ t.Errorf("taskDueDate: %q", got)
+ }
+}
+
+func TestDashboardTaskCardIsClickableAndDateOnly(t *testing.T) {
+ app := newTestApplication(t)
+ due := time.Date(2026, 9, 2, 8, 30, 0, 0, time.Local)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, taskTemplateData: taskTemplateData{Tasks: []client.Task{{ID: 7, GardenID: 3, Title: "Tomaten gießen", DueAtStart: &due}}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "dashboard.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"data-card-href='/g/3/tasks/edit/7'", ">ab 02.09.2026"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("dashboard task is missing %q: %s", want, body)
+ }
+ }
+ if strings.Contains(body, "08:30") || strings.Contains(body, ">Öffnen") {
+ t.Fatalf("dashboard task still renders time or open link: %s", body)
+ }
+}
+
+func TestTaskPaginationOnlyAppearsForMultiplePages(t *testing.T) {
+ app := newTestApplication(t)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ base := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{"status": "open"}}}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "tasks.tmpl", base)
+ if strings.Contains(response.Body.String(), "class='pagination'") {
+ t.Fatalf("single-page task list contains pagination: %s", response.Body.String())
+ }
+
+ base.Pagination = &paginationData{Page: 1, TotalPages: 2, NextURL: "/g/3/tasks?page=2&status=open"}
+ response = httptest.NewRecorder()
+ app.render(response, http.StatusOK, "tasks.tmpl", base)
+ body := response.Body.String()
+ if !strings.Contains(body, "class='pagination'") || !strings.Contains(body, "Seite 1 von 2") {
+ t.Fatalf("multi-page task list is missing pagination: %s", body)
+ }
+}
+
+func TestSettingsOfferCollectionPageSizes(t *testing.T) {
+ app := newTestApplication(t)
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "settings.tmpl", &templateData{})
+ body := response.Body.String()
+ for _, want := range []string{"name='entriesPerPage'", "Einträge pro Seite", "href='#list-settings'", "href='#view-settings'", "value='10'", "value='20'", "value='50'", "value='100'"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("settings are missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestAdminSettingsOfferSectionNavigation(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 1, Role: "application:admin", Permissions: []string{"roles:manage"}}
+ response := httptest.NewRecorder()
+ applicationRoles := []client.Role{{Name: "application:admin", Scope: "application", Label: "Administrator", Permissions: []string{"roles:manage"}}}
+ gardenRoles := []client.Role{{Name: "worker", Scope: "garden", Label: "Mitarbeiter", Permissions: []string{"garden:read"}}}
+ applicationPermissions, gardenPermissions := applicationPermissionOptions(), gardenPermissionOptions()
+ app.render(response, http.StatusOK, "admin.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user}, adminTemplateData: adminTemplateData{AdminUsers: []client.User{{ID: 2, Name: "Ada", Email: "ada@example.com", Role: "application:user"}}, ApplicationSettings: &client.ApplicationSettings{}, ApplicationRoles: applicationRoles, GardenRoles: gardenRoles, ApplicationPermissions: applicationPermissions, GardenPermissions: gardenPermissions, EnvironmentVariables: []client.EnvironmentVariable{{Component: "API", Name: "GARDOMATIC_SMTP_PASSWORD", Value: "•••••••• (gesetzt)"}}, RoleEditors: []roleEditorData{globalRoleEditor("roles-settings", "Instanzrollen", "Instanz", "application", applicationRoles, applicationPermissions, ""), globalRoleEditor("garden-role-templates", "Gartenrollen", "Garten", "garden", gardenRoles, gardenPermissions, "")}}})
+ body := response.Body.String()
+ for _, want := range []string{"class='settings-layout'", "href='#lifecycle-settings'", "href='#mail-settings'", "href='#environment-settings'", "href='#users-settings'", "href='#roles-settings'", "href='#species-categories'", "href='#task-priorities'", "id='users-settings'", "action='/admin/user-invite'", "Name Neu"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("admin settings are missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestInvitationActivationOffersInitialPasswordFields(t *testing.T) {
+ app := newTestApplication(t)
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "activate.tmpl", &templateData{commonTemplateData: commonTemplateData{Form: activationForm{Token: "invite-token", SetPassword: true, Errors: map[string]string{}}}})
+ body := response.Body.String()
+ for _, want := range []string{"name='set_password'", "name='password'", "name='password_confirm'", "Passwort festlegen"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("invitation activation is missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestCollectionPagesUseSharedPagination(t *testing.T) {
+ app := newTestApplication(t)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ user := client.User{ID: 1, Activated: true}
+ for _, test := range []struct {
+ name string
+ data *templateData
+ }{
+ {"gardens.tmpl", &templateData{}},
+ {"plant.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, plantTemplateData: plantTemplateData{PlantLocations: map[int][]client.PlantLocation{}}, locationTemplateData: locationTemplateData{LocationNames: map[int]string{}}}},
+ {"species.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, CurrentUser: &user}}},
+ {"locations.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}}},
+ {"tasks.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{}}}},
+ {"journal.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}}},
+ {"images.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{}}}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ test.data.Pagination = &paginationData{Page: 1, TotalPages: 2, NextURL: "/next?page=2"}
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, test.name, test.data)
+ if body := response.Body.String(); !strings.Contains(body, "class='pagination'") || !strings.Contains(body, "href='/next?page=2'") {
+ t.Fatalf("page does not render shared pagination: %s", body)
+ }
+ })
+ }
+}
+
+func TestTaskCardsAndEditDeleteAction(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 2, Activated: true, Role: "application:user"}
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "member"}
+ task := client.Task{ID: 7, GardenID: 3, Title: "Tomaten gießen", CreatedBy: user.ID}
+
+ listData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Filters: map[string]string{}}, taskTemplateData: taskTemplateData{Tasks: []client.Task{task}}}
+ listResponse := httptest.NewRecorder()
+ app.render(listResponse, http.StatusOK, "tasks.tmpl", listData)
+ listBody := listResponse.Body.String()
+ for _, want := range []string{"data-card-href='/g/3/tasks/edit/7'", "task-card-actions", ">Erledigt"} {
+ if !strings.Contains(listBody, want) {
+ t.Fatalf("task card is missing %q: %s", want, listBody)
+ }
+ }
+ for _, unwanted := range []string{">Bearbeiten", "action='/g/3/tasks/delete/7'", ">Erledigen"} {
+ if strings.Contains(listBody, unwanted) {
+ t.Fatalf("task card still contains %q: %s", unwanted, listBody)
+ }
+ }
+
+ formData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: taskForm{Title: task.Title, Errors: map[string]string{}}}, taskTemplateData: taskTemplateData{TaskID: 7, TaskCreatedBy: user.ID}}
+ formResponse := httptest.NewRecorder()
+ app.render(formResponse, http.StatusOK, "task_form.tmpl", formData)
+ formBody := formResponse.Body.String()
+ if !strings.Contains(formBody, "action='/g/3/tasks/delete/7'") || !strings.Contains(formBody, ">Aufgabe löschen") {
+ t.Fatalf("delete action missing from task edit page: %s", formBody)
+ }
+}
+
+func TestCustomGardenPermissionsControlResourceActions(t *testing.T) {
+ app := newTestApplication(t)
+ user := client.User{ID: 2, Activated: true}
+ garden := client.Garden{
+ ID: 3,
+ Name: "Hinterhof",
+ Role: "garden:custom",
+ Permissions: []string{"plants:create", "plants:update:own", "tasks:complete:other"},
+ }
+
+ plantsResponse := httptest.NewRecorder()
+ app.render(plantsResponse, http.StatusOK, "plant.tmpl", &templateData{
+ commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Filters: map[string]string{}},
+ plantTemplateData: plantTemplateData{
+ Plants: []client.Plant{{ID: 5, GardenID: 3, Name: "Eigene", CreatedBy: user.ID}, {ID: 6, GardenID: 3, Name: "Fremde", CreatedBy: 9}},
+ PlantLocations: map[int][]client.PlantLocation{},
+ },
+ locationTemplateData: locationTemplateData{LocationNames: map[int]string{}},
+ })
+ plantsBody := plantsResponse.Body.String()
+ for _, want := range []string{"href='/g/3/plants/new'", "action='/g/3/plants/status/5'"} {
+ if !strings.Contains(plantsBody, want) {
+ t.Errorf("permitted plant action is missing %q", want)
+ }
+ }
+ if strings.Contains(plantsBody, "action='/g/3/plants/status/6'") {
+ t.Error("update action for another user's plant was shown")
+ }
+
+ tasksResponse := httptest.NewRecorder()
+ app.render(tasksResponse, http.StatusOK, "tasks.tmpl", &templateData{
+ commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Filters: map[string]string{}},
+ taskTemplateData: taskTemplateData{Tasks: []client.Task{
+ {ID: 7, GardenID: 3, Title: "Eigene", CreatedBy: user.ID},
+ {ID: 8, GardenID: 3, Title: "Fremde", CreatedBy: 9},
+ }},
+ })
+ tasksBody := tasksResponse.Body.String()
+ if strings.Contains(tasksBody, "href='/g/3/tasks/new'") || strings.Contains(tasksBody, "data-card-href='/g/3/tasks/edit/") {
+ t.Error("task create or update action was shown without permission")
+ }
+ if strings.Contains(tasksBody, "action='/g/3/tasks/complete/7'") || !strings.Contains(tasksBody, "action='/g/3/tasks/complete/8'") {
+ t.Error("own/other task completion permissions were not distinguished")
+ }
+}
+
+func TestTaskTemplateFormAdaptsToTriggerType(t *testing.T) {
+ app := newTestApplication(t)
+ garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
+ species := client.Species{ID: 7, CommonName: "Tomate"}
+ priorities := []client.TaskPriority{{ID: 1, Name: "Dringend", Value: 8, Active: true}}
+ data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Form: taskTemplateForm{TriggerType: "month_of_year", DayFrom: 1, TriggerOffsetUnit: "day", DurationUnit: "week", RecurrenceInterval: 2, Priority: 8, Errors: map[string]string{}}}, adminTemplateData: adminTemplateData{TaskPriorities: priorities}, speciesTemplateData: speciesTemplateData{Species: []client.Species{species}, SpeciesID: 7}}
+
+ response := httptest.NewRecorder()
+ app.render(response, http.StatusOK, "task_template_form.tmpl", data)
+ body := response.Body.String()
+ for _, want := range []string{"Typ ", ">Datum", "Ab ", "name='day_from' value='1'", "Nach ", "Bis in ", "data-trigger-fields='relative' hidden disabled", ">Wochen", ">Dringend", "Die nächste Aufgabe wird erst beim Erledigen angelegt."} {
+ if !strings.Contains(body, want) {
+ t.Errorf("task template form is missing %q: %s", want, body)
+ }
+ }
+ for _, unwanted := range []string{"Jährlicher Zeitraum", ">Auslöser", "id='template-title' name='title' value='' required", "Startdatum", "Beginn relativ zum Typ", "Aufgabenzeitraum", "Nach letzter Erledigung"} {
+ if strings.Contains(body, unwanted) {
+ t.Errorf("task template form still contains %q: %s", unwanted, body)
+ }
+ }
+ if strings.Index(body, "Tag strings.Index(body, "Monat maxResponseSize {
+ return response, errors.New("client: response exceeds 2 MiB")
+ }
+
+ if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
+ return response, newAPIError(httpResponse, responseBody)
+ }
+ if output == nil || httpResponse.StatusCode == http.StatusNoContent || len(bytes.TrimSpace(responseBody)) == 0 {
+ return response, nil
+ }
+ if err := json.Unmarshal(responseBody, output); err != nil {
+ return response, fmt.Errorf("client: decode response: %w", err)
+ }
+
+ return response, nil
+}
diff --git a/lib/client/client_test.go b/lib/client/client_test.go
new file mode 100644
index 0000000..a926001
--- /dev/null
+++ b/lib/client/client_test.go
@@ -0,0 +1,233 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+type handlerTransport struct {
+ handler http.Handler
+}
+
+func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
+ recorder := httptest.NewRecorder()
+ transport.handler.ServeHTTP(recorder, request)
+ response := recorder.Result()
+ response.Request = request
+ return response, nil
+}
+
+func newTestClient(t *testing.T, handler http.Handler, options ...Option) *Client {
+ t.Helper()
+ httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
+ options = append([]Option{WithHTTPClient(httpClient)}, options...)
+ apiClient, err := New("https://api.example", options...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return apiClient
+}
+
+func TestClientSupportsBearerAuthenticationAndBasePath(t *testing.T) {
+ t.Parallel()
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/healthcheck" {
+ t.Errorf("path: got %q, want %q", r.URL.Path, "/api/v1/healthcheck")
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer secret" {
+ t.Errorf("Authorization: got %q, want %q", got, "Bearer secret")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
+ })
+
+ httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
+ apiClient, err := New("https://api.example/api", WithHTTPClient(httpClient), WithBearerToken("secret"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ health, response, err := apiClient.Healthcheck(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if response.StatusCode != http.StatusOK {
+ t.Errorf("status: got %d, want %d", response.StatusCode, http.StatusOK)
+ }
+ if health.Status != "available" || health.SystemInfo.Environment != "test" {
+ t.Errorf("unexpected health response: %+v", health)
+ }
+ if health.ServerTime.IsZero() {
+ t.Errorf("health response does not include server time: %+v", health)
+ }
+}
+
+func TestSessionClientPersistsCookies(t *testing.T) {
+ t.Parallel()
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/session":
+ http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "session-token", Path: "/", HttpOnly: true})
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/session":
+ cookie, err := r.Cookie("gardomatic_session")
+ if err != nil || cookie.Value != "session-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+
+ apiClient := newTestClient(t, handler, WithSessions())
+ user, response, err := apiClient.CreateSession(context.Background(), Credentials{
+ Email: "alice@example.com", Password: "correct horse battery staple",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if user.ID != 42 {
+ t.Errorf("user ID: got %d, want 42", user.ID)
+ }
+ if len(response.Cookies()) != 1 {
+ t.Fatalf("response cookies: got %d, want 1", len(response.Cookies()))
+ }
+
+ user, _, err = apiClient.Session(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if user.Email != "alice@example.com" {
+ t.Errorf("email: got %q, want %q", user.Email, "alice@example.com")
+ }
+}
+
+func TestForRequestUsesIsolatedIncomingSession(t *testing.T) {
+ t.Parallel()
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if _, err := r.Cookie("frontend_csrf"); !errors.Is(err, http.ErrNoCookie) {
+ t.Errorf("frontend-only cookie was forwarded to API")
+ }
+ cookie, err := r.Cookie("gardomatic_session")
+ if err != nil {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"missing session"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"user":{"id":1,"name":"` + cookie.Value + `"}}`))
+ })
+
+ baseClient := newTestClient(t, handler)
+ incoming := httptest.NewRequest(http.MethodGet, "https://frontend.example/", nil)
+ incoming.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-a"})
+ incoming.AddCookie(&http.Cookie{Name: "frontend_csrf", Value: "do-not-forward"})
+ requestClient, err := baseClient.ForRequest(incoming)
+ if err != nil {
+ t.Fatal(err)
+ }
+ user, _, err := requestClient.Session(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if user.Name != "browser-a" {
+ t.Errorf("user name: got %q, want %q", user.Name, "browser-a")
+ }
+
+ _, _, err = baseClient.Session(context.Background())
+ var apiError *APIError
+ if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("base client should have no session; got %v", err)
+ }
+}
+
+func TestValidationError(t *testing.T) {
+ t.Parallel()
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"error":{"email":"must be a valid email address"}}`))
+ })
+
+ apiClient := newTestClient(t, handler)
+ _, response, err := apiClient.RegisterUser(context.Background(), RegisterUserInput{})
+ var apiError *APIError
+ if !errors.As(err, &apiError) {
+ t.Fatalf("error type: got %T, want *APIError", err)
+ }
+ if response.StatusCode != http.StatusUnprocessableEntity {
+ t.Errorf("response status: got %d, want %d", response.StatusCode, http.StatusUnprocessableEntity)
+ }
+ if apiError.Validation["email"] != "must be a valid email address" {
+ t.Errorf("validation errors: got %#v", apiError.Validation)
+ }
+}
+
+func TestResponseBodyIsClosed(t *testing.T) {
+ t.Parallel()
+
+ closed := false
+ apiClient, err := New("https://api.example", WithHTTPClient(&http.Client{
+ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: &trackingReadCloser{
+ Reader: io.NopCloser(http.NoBody),
+ closed: &closed,
+ },
+ Request: request,
+ }, nil
+ }),
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, _ = apiClient.Healthcheck(context.Background())
+ if !closed {
+ t.Error("response body was not closed")
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return fn(request)
+}
+
+type trackingReadCloser struct {
+ Reader io.ReadCloser
+ closed *bool
+}
+
+func (reader *trackingReadCloser) Read(buffer []byte) (int, error) {
+ return reader.Reader.Read(buffer)
+}
+
+func (reader *trackingReadCloser) Close() error {
+ *reader.closed = true
+ return reader.Reader.Close()
+}
+
+func TestForwardCookies(t *testing.T) {
+ t.Parallel()
+
+ apiResponse := &http.Response{Header: make(http.Header)}
+ apiResponse.Header.Add("Set-Cookie", "gardomatic_session=token; Path=/; HttpOnly")
+ frontendResponse := httptest.NewRecorder()
+
+ ForwardCookies(frontendResponse, &Response{apiResponse})
+
+ if got := frontendResponse.Header().Values("Set-Cookie"); len(got) != 1 {
+ t.Fatalf("Set-Cookie headers: got %d, want 1", len(got))
+ }
+}
diff --git a/lib/client/context.go b/lib/client/context.go
new file mode 100644
index 0000000..44f8862
--- /dev/null
+++ b/lib/client/context.go
@@ -0,0 +1,16 @@
+package client
+
+import "context"
+
+type contextKey struct{}
+
+// NewContext attaches an API client to a context.
+func NewContext(ctx context.Context, apiClient *Client) context.Context {
+ return context.WithValue(ctx, contextKey{}, apiClient)
+}
+
+// FromContext returns the API client attached to ctx, or nil if none exists.
+func FromContext(ctx context.Context) *Client {
+ apiClient, _ := ctx.Value(contextKey{}).(*Client)
+ return apiClient
+}
diff --git a/lib/client/doc.go b/lib/client/doc.go
new file mode 100644
index 0000000..61ed640
--- /dev/null
+++ b/lib/client/doc.go
@@ -0,0 +1,2 @@
+// Package client provides a typed HTTP client for the Gardomatic JSON API.
+package client
diff --git a/lib/client/errors.go b/lib/client/errors.go
new file mode 100644
index 0000000..619eeda
--- /dev/null
+++ b/lib/client/errors.go
@@ -0,0 +1,49 @@
+package client
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+// APIError is returned for every non-2xx API response. Validation contains
+// field-specific messages for 422 responses; Message contains ordinary API
+// error strings.
+type APIError struct {
+ StatusCode int
+ Message string
+ Validation map[string]string
+ Body string
+}
+
+// Error implements error using the API message or HTTP status.
+func (e *APIError) Error() string {
+ switch {
+ case e.Message != "":
+ return fmt.Sprintf("gardomatic API: %s (%d)", e.Message, e.StatusCode)
+ case len(e.Validation) != 0:
+ return fmt.Sprintf("gardomatic API: validation failed (%d)", e.StatusCode)
+ default:
+ return fmt.Sprintf("gardomatic API: request failed (%d)", e.StatusCode)
+ }
+}
+
+func newAPIError(response *http.Response, body []byte) *APIError {
+ apiError := &APIError{
+ StatusCode: response.StatusCode,
+ Body: strings.TrimSpace(string(body)),
+ }
+
+ var envelope struct {
+ Error json.RawMessage `json:"error"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil || len(envelope.Error) == 0 {
+ return apiError
+ }
+ if err := json.Unmarshal(envelope.Error, &apiError.Message); err == nil {
+ return apiError
+ }
+ _ = json.Unmarshal(envelope.Error, &apiError.Validation)
+ return apiError
+}
diff --git a/lib/client/garden_members.go b/lib/client/garden_members.go
new file mode 100644
index 0000000..c797f52
--- /dev/null
+++ b/lib/client/garden_members.go
@@ -0,0 +1,67 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// GardenMembers lists all members of a garden.
+func (c *Client) GardenMembers(ctx context.Context, gardenID int) ([]GardenMember, *Response, error) {
+ var envelope struct {
+ Members []GardenMember `json:"members"`
+ }
+ response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/members", nil, &envelope)
+ return envelope.Members, response, err
+}
+
+// UpdateGardenMember assigns a garden role to an existing member.
+func (c *Client) UpdateGardenMember(ctx context.Context, gardenID, userID int, role string) (GardenMember, *Response, error) {
+ var envelope struct {
+ Member GardenMember `json:"member"`
+ }
+ response, err := c.do(ctx, http.MethodPatch, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID), map[string]string{"role": role}, &envelope)
+ return envelope.Member, response, err
+}
+
+// DeleteGardenMember removes a user from a garden.
+func (c *Client) DeleteGardenMember(ctx context.Context, gardenID, userID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID), nil, nil)
+}
+
+// TransferGardenOwnership makes userID the garden owner.
+func (c *Client) TransferGardenOwnership(ctx context.Context, gardenID, userID int) (*Response, error) {
+ return c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID)+"/transfer-ownership", nil, nil)
+}
+
+// GardenInvites lists pending invitations for a garden.
+func (c *Client) GardenInvites(ctx context.Context, gardenID int) ([]GardenInvite, *Response, error) {
+ var envelope struct {
+ Invites []GardenInvite `json:"invites"`
+ }
+ response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/invites", nil, &envelope)
+ return envelope.Invites, response, err
+}
+
+// CreateGardenInvite creates or replaces an invitation for an email address.
+func (c *Client) CreateGardenInvite(ctx context.Context, gardenID int, input GardenInviteInput) (GardenInvite, *Response, error) {
+ var envelope struct {
+ Invite GardenInvite `json:"invite"`
+ }
+ response, err := c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/invites", input, &envelope)
+ return envelope.Invite, response, err
+}
+
+// DeleteGardenInvite revokes a pending invitation.
+func (c *Client) DeleteGardenInvite(ctx context.Context, gardenID, inviteID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/invites/"+strconv.Itoa(inviteID), nil, nil)
+}
+
+// AcceptGardenInvite joins the current user to the invited garden.
+func (c *Client) AcceptGardenInvite(ctx context.Context, token string) (GardenMember, *Response, error) {
+ var envelope struct {
+ Member GardenMember `json:"member"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/invites/"+token+"/accept", nil, &envelope)
+ return envelope.Member, response, err
+}
diff --git a/lib/client/gardens.go b/lib/client/gardens.go
new file mode 100644
index 0000000..1700372
--- /dev/null
+++ b/lib/client/gardens.go
@@ -0,0 +1,52 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreateGarden creates a garden owned by the authenticated user.
+func (c *Client) CreateGarden(ctx context.Context, input CreateGardenInput) (Garden, *Response, error) {
+ var envelope struct {
+ Garden Garden `json:"garden"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/gardens", input, &envelope)
+ return envelope.Garden, response, err
+}
+
+// Gardens lists gardens visible to the authenticated user.
+func (c *Client) Gardens(ctx context.Context) ([]Garden, *Response, error) {
+ var envelope struct {
+ Gardens []Garden `json:"gardens"`
+ }
+ response, err := c.do(ctx, http.MethodGet, "v1/gardens", nil, &envelope)
+ return envelope.Gardens, response, err
+}
+
+// Garden returns one garden visible to the authenticated user.
+func (c *Client) Garden(ctx context.Context, gardenID int) (Garden, *Response, error) {
+ var envelope struct {
+ Garden Garden `json:"garden"`
+ }
+ response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID), nil, &envelope)
+ return envelope.Garden, response, err
+}
+
+// UpdateGarden partially updates a garden.
+func (c *Client) UpdateGarden(ctx context.Context, gardenID int, input UpdateGardenInput) (Garden, *Response, error) {
+ var envelope struct {
+ Garden Garden `json:"garden"`
+ }
+ response, err := c.do(ctx, http.MethodPatch, gardenPath(gardenID), input, &envelope)
+ return envelope.Garden, response, err
+}
+
+// DeleteGarden permanently deletes a garden and its dependent records.
+func (c *Client) DeleteGarden(ctx context.Context, gardenID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, gardenPath(gardenID), nil, nil)
+}
+
+func gardenPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID)
+}
diff --git a/lib/client/gardens_test.go b/lib/client/gardens_test.go
new file mode 100644
index 0000000..ce4b5c1
--- /dev/null
+++ b/lib/client/gardens_test.go
@@ -0,0 +1,89 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestGardenCanUsesEffectivePermissions(t *testing.T) {
+ custom := Garden{Role: "garden:custom", Permissions: []string{"plants:create"}}
+ if !custom.Can("plants:create") || custom.Can("garden:update") {
+ t.Fatalf("custom role did not use effective permissions: %+v", custom)
+ }
+
+ deniedOverride := Garden{Role: "owner", Permissions: []string{"garden:read"}}
+ if deniedOverride.Can("garden:update") {
+ t.Fatal("explicit permissions must override the built-in role fallback")
+ }
+
+ legacyOwner := Garden{Role: "owner"}
+ if !legacyOwner.Can("garden:update") {
+ t.Fatal("legacy built-in role response should retain compatibility")
+ }
+ if legacyOwner.Can("unknown:permission") {
+ t.Fatal("legacy role fallback must reject unknown permissions")
+ }
+}
+
+func TestGardenClientCRUD(t *testing.T) {
+ t.Parallel()
+
+ name := "Neu"
+ description := "Beschreibung"
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens":
+ var input CreateGardenInput
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ t.Error(err)
+ }
+ if input.Name != "Hinterhof" {
+ t.Errorf("create name: got %q, want %q", input.Name, "Hinterhof")
+ }
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Hinterhof","version":1}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens":
+ _, _ = w.Write([]byte(`{"gardens":[{"id":12,"name":"Hinterhof","version":1}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/12":
+ _, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Hinterhof","version":1}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/12":
+ var input UpdateGardenInput
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ t.Error(err)
+ }
+ if input.Name == nil || *input.Name != name || input.Description == nil || *input.Description != description {
+ t.Errorf("update input: got %+v", input)
+ }
+ _, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Neu","description":"Beschreibung","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/12":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+
+ apiClient := newTestClient(t, handler)
+ created, response, err := apiClient.CreateGarden(context.Background(), CreateGardenInput{Name: "Hinterhof"})
+ if err != nil || response.StatusCode != http.StatusCreated || created.ID != 12 {
+ t.Fatalf("CreateGarden(): garden=%+v response=%v err=%v", created, response, err)
+ }
+ gardens, _, err := apiClient.Gardens(context.Background())
+ if err != nil || len(gardens) != 1 || gardens[0].ID != 12 {
+ t.Fatalf("Gardens(): gardens=%+v err=%v", gardens, err)
+ }
+ garden, _, err := apiClient.Garden(context.Background(), 12)
+ if err != nil || garden.Name != "Hinterhof" {
+ t.Fatalf("Garden(): garden=%+v err=%v", garden, err)
+ }
+ updated, _, err := apiClient.UpdateGarden(context.Background(), 12, UpdateGardenInput{Name: &name, Description: &description})
+ if err != nil || updated.Version != 2 || updated.Name != name {
+ t.Fatalf("UpdateGarden(): garden=%+v err=%v", updated, err)
+ }
+ response, err = apiClient.DeleteGarden(context.Background(), 12)
+ if err != nil || response.StatusCode != http.StatusNoContent {
+ t.Fatalf("DeleteGarden(): response=%v err=%v", response, err)
+ }
+}
diff --git a/lib/client/healthcheck.go b/lib/client/healthcheck.go
new file mode 100644
index 0000000..d103744
--- /dev/null
+++ b/lib/client/healthcheck.go
@@ -0,0 +1,13 @@
+package client
+
+import (
+ "context"
+ "net/http"
+)
+
+// Healthcheck returns API availability and build information.
+func (c *Client) Healthcheck(ctx context.Context) (Health, *Response, error) {
+ var health Health
+ response, err := c.do(ctx, http.MethodGet, "v1/healthcheck", nil, &health)
+ return health, response, err
+}
diff --git a/lib/client/images.go b/lib/client/images.go
new file mode 100644
index 0000000..6159ee0
--- /dev/null
+++ b/lib/client/images.go
@@ -0,0 +1,71 @@
+package client
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Image describes an item in a garden's reusable image 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"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Images lists a garden's images, optionally filtered by text and source.
+func (c *Client) Images(ctx context.Context, gardenID int, query, source string) ([]Image, *Response, error) {
+ values := url.Values{}
+ if query != "" {
+ values.Set("q", query)
+ }
+ if source != "" {
+ values.Set("source", source)
+ }
+ path := "v1/gardens/" + strconv.Itoa(gardenID) + "/images"
+ if encoded := values.Encode(); encoded != "" {
+ path += "?" + encoded
+ }
+ var envelope struct {
+ Images []Image `json:"images"`
+ }
+ response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
+ return envelope.Images, response, err
+}
+
+// ImageData downloads an image body and returns its media type.
+func (c *Client) ImageData(ctx context.Context, gardenID, imageID int) ([]byte, string, *Response, error) {
+ path := "v1/gardens/" + strconv.Itoa(gardenID) + "/images/" + strconv.Itoa(imageID)
+ relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
+ if err != nil {
+ return nil, "", nil, err
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(relativeURL).String(), nil)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ request.Header = c.headers.Clone()
+ request.Header.Set("Accept", "image/*")
+ if c.bearerToken != "" {
+ request.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ }
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ defer response.Body.Close()
+ wrapped := &Response{Response: response}
+ data, err := io.ReadAll(response.Body)
+ if err == nil && (response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices) {
+ return nil, response.Header.Get("Content-Type"), wrapped, newAPIError(response, data)
+ }
+ return data, response.Header.Get("Content-Type"), wrapped, err
+}
diff --git a/lib/client/journal.go b/lib/client/journal.go
new file mode 100644
index 0000000..3302ac1
--- /dev/null
+++ b/lib/client/journal.go
@@ -0,0 +1,188 @@
+package client
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+func journalCollectionPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID) + "/journal"
+}
+
+// Supported journal entry types.
+const (
+ JournalEntryTypeJournal = "journal"
+ JournalEntryTypePinboard = "pinboard"
+)
+
+func journalEntryPath(gardenID, entryID int) string {
+ return journalCollectionPath(gardenID) + "/" + strconv.Itoa(entryID)
+}
+func journalAttachmentPath(gardenID, entryID, attachmentID int) string {
+ return journalEntryPath(gardenID, entryID) + "/attachments/" + strconv.Itoa(attachmentID)
+}
+
+// CreateJournalEntry adds a journal or pinboard entry to a garden.
+func (c *Client) CreateJournalEntry(ctx context.Context, gardenID int, input JournalEntryInput) (JournalEntry, *Response, error) {
+ return c.writeJournalEntry(ctx, http.MethodPost, journalCollectionPath(gardenID), input)
+}
+
+// JournalEntries lists all journal and pinboard entries in a garden.
+func (c *Client) JournalEntries(ctx context.Context, gardenID int) ([]JournalEntry, *Response, error) {
+ return c.JournalEntriesByType(ctx, gardenID, JournalEntryTypeJournal)
+}
+
+// JournalEntriesByType lists garden entries of one journal entry type.
+func (c *Client) JournalEntriesByType(ctx context.Context, gardenID int, entryType string) ([]JournalEntry, *Response, error) {
+ var envelope struct {
+ JournalEntries []JournalEntry `json:"journal_entries"`
+ }
+ path := journalCollectionPath(gardenID)
+ if entryType != "" && entryType != JournalEntryTypeJournal {
+ path += "?type=" + url.QueryEscape(entryType)
+ }
+ response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
+ return envelope.JournalEntries, response, err
+}
+
+// GardenTags lists tag names available within a garden.
+func (c *Client) GardenTags(ctx context.Context, gardenID int) ([]string, *Response, error) {
+ var envelope struct {
+ Tags []string `json:"tags"`
+ }
+ response, err := c.do(ctx, http.MethodGet, "v1/gardens/"+strconv.Itoa(gardenID)+"/tags", nil, &envelope)
+ return envelope.Tags, response, err
+}
+
+// JournalEntry returns one entry within its garden.
+func (c *Client) JournalEntry(ctx context.Context, gardenID, entryID int) (JournalEntry, *Response, error) {
+ var envelope struct {
+ JournalEntry JournalEntry `json:"journal_entry"`
+ }
+ response, err := c.do(ctx, http.MethodGet, journalEntryPath(gardenID, entryID), nil, &envelope)
+ return envelope.JournalEntry, response, err
+}
+
+// UpdateJournalEntry changes an entry within its garden.
+func (c *Client) UpdateJournalEntry(ctx context.Context, gardenID, entryID int, input JournalEntryInput) (JournalEntry, *Response, error) {
+ return c.writeJournalEntry(ctx, http.MethodPatch, journalEntryPath(gardenID, entryID), input)
+}
+
+func (c *Client) writeJournalEntry(ctx context.Context, method, path string, input JournalEntryInput) (JournalEntry, *Response, error) {
+ var envelope struct {
+ JournalEntry JournalEntry `json:"journal_entry"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.JournalEntry, response, err
+}
+
+// DeleteJournalEntry removes an entry and its attachment records.
+func (c *Client) DeleteJournalEntry(ctx context.Context, gardenID, entryID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, journalEntryPath(gardenID, entryID), nil, nil)
+}
+
+// UploadJournalAttachment adds binary media to an entry.
+func (c *Client) UploadJournalAttachment(ctx context.Context, gardenID, entryID int, fileName, mediaType string, data []byte) (JournalAttachment, *Response, error) {
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": fileName}))
+ header.Set("Content-Type", mediaType)
+ part, err := w.CreatePart(header)
+ if err != nil {
+ return JournalAttachment{}, nil, err
+ }
+ if _, err = part.Write(data); err != nil {
+ return JournalAttachment{}, nil, err
+ }
+ if err = w.Close(); err != nil {
+ return JournalAttachment{}, nil, err
+ }
+ response, responseBody, err := c.doBinary(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments", &body, w.FormDataContentType(), 2<<20)
+ if err != nil {
+ return JournalAttachment{}, response, err
+ }
+ var envelope struct {
+ Attachment JournalAttachment `json:"attachment"`
+ }
+ if err = json.Unmarshal(responseBody, &envelope); err != nil {
+ return JournalAttachment{}, response, fmt.Errorf("client: decode response: %w", err)
+ }
+ return envelope.Attachment, response, nil
+}
+
+// AttachJournalLibraryImage links an existing garden image to an entry.
+func (c *Client) AttachJournalLibraryImage(ctx context.Context, gardenID, entryID, imageID int) (JournalAttachment, *Response, error) {
+ var envelope struct {
+ Attachment JournalAttachment `json:"attachment"`
+ }
+ response, err := c.do(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments/library", map[string]int{"image_id": imageID}, &envelope)
+ return envelope.Attachment, response, err
+}
+
+// JournalAttachment downloads attachment metadata and binary data.
+func (c *Client) JournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (JournalAttachmentData, *Response, error) {
+ response, data, err := c.doBinary(ctx, http.MethodGet, journalAttachmentPath(gardenID, entryID, attachmentID), nil, "", (25<<20)+1)
+ if err != nil {
+ return JournalAttachmentData{}, response, err
+ }
+ attachment := JournalAttachmentData{Data: data}
+ attachment.ID, attachment.EntryID = attachmentID, entryID
+ attachment.MediaType = strings.Split(response.Header.Get("Content-Type"), ";")[0]
+ if _, params, parseErr := mime.ParseMediaType(response.Header.Get("Content-Disposition")); parseErr == nil {
+ attachment.FileName = params["filename"]
+ }
+ attachment.Size = int64(len(data))
+ return attachment, response, nil
+}
+
+// DeleteJournalAttachment removes an attachment from an entry.
+func (c *Client) DeleteJournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, journalAttachmentPath(gardenID, entryID, attachmentID), nil, nil)
+}
+
+func (c *Client) doBinary(ctx context.Context, method, path string, body io.Reader, contentType string, limit int64) (*Response, []byte, error) {
+ relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
+ if err != nil {
+ return nil, nil, err
+ }
+ request, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(relativeURL).String(), body)
+ if err != nil {
+ return nil, nil, err
+ }
+ request.Header = c.headers.Clone()
+ request.Header.Set("Accept", "application/json, image/*, video/*, audio/*")
+ if contentType != "" {
+ request.Header.Set("Content-Type", contentType)
+ }
+ if c.bearerToken != "" {
+ request.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ }
+ httpResponse, err := c.httpClient.Do(request)
+ if err != nil {
+ return nil, nil, fmt.Errorf("client: execute request: %w", err)
+ }
+ response := &Response{httpResponse}
+ defer httpResponse.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(httpResponse.Body, limit+1))
+ if err != nil {
+ return response, nil, err
+ }
+ if int64(len(data)) > limit {
+ return response, nil, fmt.Errorf("client: response exceeds %d bytes", limit)
+ }
+ if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
+ return response, nil, newAPIError(httpResponse, data)
+ }
+ return response, data, nil
+}
diff --git a/lib/client/journal_test.go b/lib/client/journal_test.go
new file mode 100644
index 0000000..44b4b42
--- /dev/null
+++ b/lib/client/journal_test.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestJournalClientAndAttachments(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/journal":
+ _, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"title":"Ernte"}]}`))
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/journal/8/attachments":
+ if err := r.ParseMultipartForm(1024); err != nil {
+ t.Error(err)
+ http.Error(w, "bad multipart", 400)
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ t.Error(err)
+ http.Error(w, "missing file", 400)
+ return
+ }
+ defer file.Close()
+ data, _ := io.ReadAll(file)
+ if header.Filename != "ernte.jpg" || string(data) != "jpeg" {
+ t.Errorf("unexpected upload: %q %q", header.Filename, data)
+ }
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"attachment":{"id":9,"entry_id":8,"file_name":"ernte.jpg","media_type":"image/jpeg","size":4}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/journal/8/attachments/9":
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Header().Set("Content-Disposition", `inline; filename="ernte.jpg"`)
+ _, _ = w.Write([]byte("jpeg"))
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ apiClient := newTestClient(t, handler)
+ entries, _, err := apiClient.JournalEntries(context.Background(), 4)
+ if err != nil || len(entries) != 1 || entries[0].Title != "Ernte" {
+ t.Fatalf("entries: %#v, %v", entries, err)
+ }
+ attachment, _, err := apiClient.UploadJournalAttachment(context.Background(), 4, 8, "ernte.jpg", "image/jpeg", []byte("jpeg"))
+ if err != nil || attachment.ID != 9 {
+ t.Fatalf("upload: %#v, %v", attachment, err)
+ }
+ download, _, err := apiClient.JournalAttachment(context.Background(), 4, 8, 9)
+ if err != nil || download.FileName != "ernte.jpg" || !strings.EqualFold(string(download.Data), "jpeg") {
+ t.Fatalf("download: %#v, %v", download, err)
+ }
+}
diff --git a/lib/client/locations.go b/lib/client/locations.go
new file mode 100644
index 0000000..6cf2c00
--- /dev/null
+++ b/lib/client/locations.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreateLocation creates a location within a garden.
+func (c *Client) CreateLocation(ctx context.Context, gardenID int, input LocationInput) (Location, *Response, error) {
+ return c.writeLocation(ctx, http.MethodPost, locationCollectionPath(gardenID), input)
+}
+
+// Locations lists all locations in a garden.
+func (c *Client) Locations(ctx context.Context, gardenID int) ([]Location, *Response, error) {
+ var envelope struct {
+ Locations []Location `json:"locations"`
+ }
+ response, err := c.do(ctx, http.MethodGet, locationCollectionPath(gardenID), nil, &envelope)
+ return envelope.Locations, response, err
+}
+
+// Location returns one location from a garden.
+func (c *Client) Location(ctx context.Context, gardenID, locationID int) (Location, *Response, error) {
+ var envelope struct {
+ Location Location `json:"location"`
+ }
+ response, err := c.do(ctx, http.MethodGet, locationPath(gardenID, locationID), nil, &envelope)
+ return envelope.Location, response, err
+}
+
+// UpdateLocation partially updates a location.
+func (c *Client) UpdateLocation(ctx context.Context, gardenID, locationID int, input LocationInput) (Location, *Response, error) {
+ return c.writeLocation(ctx, http.MethodPatch, locationPath(gardenID, locationID), input)
+}
+
+// DeleteLocation deletes a location.
+func (c *Client) DeleteLocation(ctx context.Context, gardenID, locationID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, locationPath(gardenID, locationID), nil, nil)
+}
+
+func (c *Client) writeLocation(ctx context.Context, method, path string, input LocationInput) (Location, *Response, error) {
+ var envelope struct {
+ Location Location `json:"location"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Location, response, err
+}
+
+func locationCollectionPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID) + "/locations"
+}
+
+func locationPath(gardenID, locationID int) string {
+ return locationCollectionPath(gardenID) + "/" + strconv.Itoa(locationID)
+}
diff --git a/lib/client/locations_test.go b/lib/client/locations_test.go
new file mode 100644
index 0000000..383b980
--- /dev/null
+++ b/lib/client/locations_test.go
@@ -0,0 +1,75 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+)
+
+func TestLocationAndAssignmentClientPaths(t *testing.T) {
+ t.Parallel()
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/locations":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet","version":1}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/locations":
+ _, _ = w.Write([]byte(`{"locations":[{"id":8,"garden_id":4,"name":"Beet"}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/locations/8":
+ _, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/locations/8":
+ _, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/locations/8":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/plants/9/locations":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"plant_location":{"id":10,"plant_id":9,"location_id":8,"quantity":2,"version":1}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants/9/locations":
+ _, _ = w.Write([]byte(`{"plant_locations":[{"id":10,"plant_id":9,"location_id":8,"quantity":2}]}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/plants/9/locations/10":
+ _, _ = w.Write([]byte(`{"plant_location":{"id":10,"plant_id":9,"location_id":8,"quantity":3,"version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/plants/9/locations/10":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ apiClient := newTestClient(t, handler)
+ name := "Beet"
+ location, _, err := apiClient.CreateLocation(context.Background(), 4, LocationInput{Name: &name})
+ if err != nil || location.ID != 8 {
+ t.Fatalf("CreateLocation(): location=%+v err=%v", location, err)
+ }
+ locations, _, err := apiClient.Locations(context.Background(), 4)
+ if err != nil || len(locations) != 1 {
+ t.Fatalf("Locations(): locations=%+v err=%v", locations, err)
+ }
+ if _, _, err = apiClient.Location(context.Background(), 4, 8); err != nil {
+ t.Fatalf("Location(): %v", err)
+ }
+ updated, _, err := apiClient.UpdateLocation(context.Background(), 4, 8, LocationInput{Name: &name})
+ if err != nil || updated.Version != 2 {
+ t.Fatalf("UpdateLocation(): location=%+v err=%v", updated, err)
+ }
+ if _, err = apiClient.DeleteLocation(context.Background(), 4, 8); err != nil {
+ t.Fatalf("DeleteLocation(): %v", err)
+ }
+ locationID, quantity := 8, 2
+ assignment, _, err := apiClient.CreatePlantLocation(context.Background(), 4, 9, PlantLocationInput{LocationID: &locationID, Quantity: &quantity})
+ if err != nil || assignment.ID != 10 {
+ t.Fatalf("CreatePlantLocation(): assignment=%+v err=%v", assignment, err)
+ }
+ assignments, _, err := apiClient.PlantLocations(context.Background(), 4, 9)
+ if err != nil || len(assignments) != 1 {
+ t.Fatalf("PlantLocations(): assignments=%+v err=%v", assignments, err)
+ }
+ quantity = 3
+ assignment, _, err = apiClient.UpdatePlantLocation(context.Background(), 4, 9, 10, PlantLocationInput{Quantity: &quantity})
+ if err != nil || assignment.Version != 2 {
+ t.Fatalf("UpdatePlantLocation(): assignment=%+v err=%v", assignment, err)
+ }
+ if _, err = apiClient.DeletePlantLocation(context.Background(), 4, 9, 10); err != nil {
+ t.Fatalf("DeletePlantLocation(): %v", err)
+ }
+}
diff --git a/lib/client/models.go b/lib/client/models.go
new file mode 100644
index 0000000..2577e09
--- /dev/null
+++ b/lib/client/models.go
@@ -0,0 +1,634 @@
+package client
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// User is the public user representation returned by the API.
+type User struct {
+ ID int `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Color string `json:"color"`
+ Activated bool `json:"activated"`
+ Role string `json:"role"`
+ Permissions []string `json:"permissions"`
+}
+
+// AdminUserInviteInput contains the identity fields for an administrator-created
+// account invitation.
+type AdminUserInviteInput struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+// IsAdmin reports whether the user can manage application roles.
+func (user User) IsAdmin() bool { return user.Can("roles:manage") }
+
+// Can reports whether the API-resolved application permissions contain permission.
+func (user User) Can(permission string) bool {
+ for _, granted := range user.Permissions {
+ if granted == permission || granted == "*" {
+ return true
+ }
+ }
+ return false
+}
+
+// AccountSession describes one server-side login session for the current user.
+type AccountSession struct {
+ ID string `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Current bool `json:"current"`
+}
+
+// Garden is the public garden representation returned by the API.
+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 string `json:"role"`
+ Permissions []string `json:"permissions"`
+}
+
+// Can reports whether the authenticated member has a concrete permission in
+// this garden. The API resolves role defaults and garden-specific overrides.
+func (garden Garden) Can(permission string) bool {
+ for _, granted := range garden.Permissions {
+ if granted == permission || granted == "*" || granted == "garden:*" && permission != "garden:delete" {
+ return true
+ }
+ }
+ if garden.Permissions != nil {
+ return false
+ }
+ // Compatibility for older API responses. Custom roles deliberately have no
+ // fallback: their effective permissions must always come from the API.
+ switch garden.Role {
+ case "owner":
+ return validLegacyGardenPermission(permission)
+ case "admin":
+ return validLegacyGardenPermission(permission) && permission != "garden:delete"
+ case "member":
+ switch permission {
+ case "garden:read", "content:write",
+ "plants:create", "plants:read:own", "plants:read:other", "plants:update:own", "plants:delete:own",
+ "locations:create", "locations:read:own", "locations:read:other", "locations:update:own", "locations:delete:own",
+ "tasks:create", "tasks:read:own", "tasks:read:other", "tasks:update:own", "tasks:delete:own", "tasks:complete:own", "tasks:complete:other":
+ return true
+ }
+ case "viewer":
+ return permission == "garden:read" || permission == "plants:read:own" || permission == "plants:read:other" ||
+ permission == "locations:read:own" || permission == "locations:read:other" || permission == "tasks:read:own" || permission == "tasks:read:other"
+ case "worker":
+ return permission == "garden:read" || permission == "tasks:read:own" || permission == "tasks:read:other" ||
+ permission == "tasks:complete:own" || permission == "tasks:complete:other"
+ }
+ return false
+}
+
+func validLegacyGardenPermission(permission string) bool {
+ switch permission {
+ case "garden:read", "garden:update", "garden:delete", "content:write", "members:write", "species:write",
+ "plants:create", "plants:read:own", "plants:read:other", "plants:update:own", "plants:update:other", "plants:delete:own", "plants:delete:other",
+ "locations:create", "locations:read:own", "locations:read:other", "locations:update:own", "locations:update:other", "locations:delete:own", "locations:delete:other",
+ "tasks:create", "tasks:read:own", "tasks:read:other", "tasks:update:own", "tasks:update:other", "tasks:delete:own", "tasks:delete:other", "tasks:complete:own", "tasks:complete:other":
+ return true
+ }
+ return false
+}
+
+// GardenMember describes a user's role within one garden.
+type GardenMember struct {
+ GardenID int `json:"garden_id"`
+ UserID int `json:"user_id"`
+ Role string `json:"role"`
+ JoinedAt time.Time `json:"joined_at"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+// GardenInvite describes a pending invitation to a garden.
+type GardenInvite struct {
+ ID int `json:"id"`
+ GardenID int `json:"garden_id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ InvitedBy int `json:"invited_by"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// GardenInviteInput contains the recipient and role for a garden invitation.
+type GardenInviteInput struct {
+ Email string `json:"email"`
+ Role string `json:"role"`
+}
+
+// Role is an application or garden role with its base permissions.
+type Role struct {
+ Name string `json:"name"`
+ Scope string `json:"scope"`
+ GardenID *int `json:"garden_id,omitempty"`
+ Label string `json:"label"`
+ System bool `json:"system"`
+ Permissions []string `json:"permissions"`
+}
+
+// RoleInput contains writable role fields.
+type RoleInput struct {
+ Name string `json:"name,omitempty"`
+ Scope string `json:"scope,omitempty"`
+ Label string `json:"label"`
+ Permissions []string `json:"permissions"`
+}
+
+// GardenRoleSetting combines a role template with its effective permissions in
+// one garden.
+type GardenRoleSetting struct {
+ Role Role `json:"role"`
+ EffectivePermissions []string `json:"effective_permissions"`
+}
+
+// CreateGardenInput contains writable fields for a new garden.
+type CreateGardenInput struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ImageData string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,omitempty"`
+}
+
+// UpdateGardenInput contains optional garden fields for a partial update.
+type UpdateGardenInput struct {
+ Name *string `json:"name,omitempty"`
+ Description *string `json:"description,omitempty"`
+ ImageData *string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,omitempty"`
+}
+
+// Species describes global or garden-specific plant master data.
+type Species struct {
+ ID int `json:"id"`
+ GardenID *int `json:"garden_id,omitempty"`
+ CommonName string `json:"common_name"`
+ Cultivar string `json:"cultivar"`
+ BotanicalName string `json:"botanical_name"`
+ CategoryID *int `json:"category_id,omitempty"`
+ Category string `json:"category"`
+ SunExposure *string `json:"sun_exposure,omitempty"`
+ SoilCondition *string `json:"soil_condition,omitempty"`
+ SoilReaction *string `json:"soil_reaction,omitempty"`
+ WinterProtection *string `json:"winter_protection,omitempty"`
+ SpacingCM *int `json:"spacing_cm,omitempty"`
+ HeightCM *int `json:"height_cm,omitempty"`
+ SowMonthFrom *int `json:"sow_month_from,omitempty"`
+ SowDayFrom *int `json:"sow_day_from,omitempty"`
+ SowMonthTo *int `json:"sow_month_to,omitempty"`
+ SowDayTo *int `json:"sow_day_to,omitempty"`
+ PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
+ PlantingDayFrom *int `json:"planting_day_from,omitempty"`
+ PlantingMonthTo *int `json:"planting_month_to,omitempty"`
+ PlantingDayTo *int `json:"planting_day_to,omitempty"`
+ HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
+ HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
+ HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
+ HarvestDayTo *int `json:"harvest_day_to,omitempty"`
+ Notes string `json:"notes"`
+ ImageData string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,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"`
+}
+
+// SpeciesInput contains optional species fields used for creation and updates.
+type SpeciesInput struct {
+ Global bool `json:"global,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ CommonName *string `json:"common_name,omitempty"`
+ Cultivar *string `json:"cultivar,omitempty"`
+ BotanicalName *string `json:"botanical_name,omitempty"`
+ CategoryID *int `json:"category_id,omitempty"`
+ ClearCategoryID bool `json:"clear_category_id,omitempty"`
+ SunExposure *string `json:"sun_exposure,omitempty"`
+ SoilCondition *string `json:"soil_condition,omitempty"`
+ SoilReaction *string `json:"soil_reaction,omitempty"`
+ WinterProtection *string `json:"winter_protection,omitempty"`
+ SpacingCM *int `json:"spacing_cm,omitempty"`
+ HeightCM *int `json:"height_cm,omitempty"`
+ SowMonthFrom *int `json:"sow_month_from,omitempty"`
+ SowDayFrom *int `json:"sow_day_from,omitempty"`
+ ClearSowDayFrom bool `json:"clear_sow_day_from,omitempty"`
+ SowMonthTo *int `json:"sow_month_to,omitempty"`
+ SowDayTo *int `json:"sow_day_to,omitempty"`
+ ClearSowDayTo bool `json:"clear_sow_day_to,omitempty"`
+ PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
+ PlantingDayFrom *int `json:"planting_day_from,omitempty"`
+ ClearPlantingDayFrom bool `json:"clear_planting_day_from,omitempty"`
+ PlantingMonthTo *int `json:"planting_month_to,omitempty"`
+ PlantingDayTo *int `json:"planting_day_to,omitempty"`
+ ClearPlantingDayTo bool `json:"clear_planting_day_to,omitempty"`
+ HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
+ HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
+ ClearHarvestDayFrom bool `json:"clear_harvest_day_from,omitempty"`
+ HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
+ HarvestDayTo *int `json:"harvest_day_to,omitempty"`
+ ClearHarvestDayTo bool `json:"clear_harvest_day_to,omitempty"`
+ ClearSowRange bool `json:"clear_sow_range,omitempty"`
+ ClearPlantingRange bool `json:"clear_planting_range,omitempty"`
+ ClearHarvestRange bool `json:"clear_harvest_range,omitempty"`
+ Notes *string `json:"notes,omitempty"`
+ ImageData *string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,omitempty"`
+ Attributes json.RawMessage `json:"attributes,omitempty"`
+}
+
+// SpeciesCategory classifies species and optionally supplies a lifecycle.
+type SpeciesCategory struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ SortOrder int `json:"sort_order"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+ Lifecycle *string `json:"lifecycle,omitempty"`
+}
+
+// SpeciesCategoryInput contains writable category fields.
+type SpeciesCategoryInput struct {
+ Name *string `json:"name,omitempty"`
+ SortOrder *int `json:"sort_order,omitempty"`
+ Active *bool `json:"active,omitempty"`
+ Lifecycle *string `json:"lifecycle,omitempty"`
+}
+
+// TaskPriority maps a display name to the numeric priority stored on tasks.
+type TaskPriority struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Value int `json:"value"`
+ SortOrder int `json:"sort_order"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+}
+
+// TaskPriorityInput contains writable priority-catalogue fields.
+type TaskPriorityInput struct {
+ Name *string `json:"name,omitempty"`
+ Value *int `json:"value,omitempty"`
+ SortOrder *int `json:"sort_order,omitempty"`
+ Active *bool `json:"active,omitempty"`
+}
+
+// Plant represents a plant instance owned 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"`
+}
+
+// PlantInput contains optional plant fields used for creation and updates.
+type PlantInput struct {
+ Tags []string `json:"tags,omitempty"`
+ SpeciesID *int `json:"species_id,omitempty"`
+ ClearSpeciesID bool `json:"clear_species_id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Notes *string `json:"notes,omitempty"`
+ ImageData *string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,omitempty"`
+ AcquiredAt *time.Time `json:"acquired_at,omitempty"`
+ ClearAcquiredAt bool `json:"clear_acquired_at,omitempty"`
+ Status *string `json:"status,omitempty"`
+ RemovedAt *time.Time `json:"removed_at,omitempty"`
+ Attributes json.RawMessage `json:"attributes,omitempty"`
+}
+
+// Location describes a hierarchical place within a garden.
+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"`
+}
+
+// LocationInput contains optional location fields used for creation and updates.
+type LocationInput struct {
+ ParentID *int `json:"parent_id,omitempty"`
+ ClearParentID bool `json:"clear_parent_id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Description *string `json:"description,omitempty"`
+ ImageData *string `json:"image_data,omitempty"`
+ ImageID *int `json:"image_id,omitempty"`
+ Kind *string `json:"kind,omitempty"`
+ 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,omitempty"`
+}
+
+// PlantLocation represents a plant's assignment to a location.
+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"`
+}
+
+// PlantLocationInput contains optional assignment fields used for creation and updates.
+type PlantLocationInput struct {
+ LocationID *int `json:"location_id,omitempty"`
+ Quantity *int `json:"quantity,omitempty"`
+ PlantedAt *time.Time `json:"planted_at,omitempty"`
+ ClearPlantedAt bool `json:"clear_planted_at,omitempty"`
+ RemovedAt *time.Time `json:"removed_at,omitempty"`
+ Notes *string `json:"notes,omitempty"`
+}
+
+// Task represents a manual or generated garden work item.
+type Task struct {
+ ID int `json:"id"`
+ GardenID int `json:"garden_id"`
+ PlantID *int `json:"plant_id,omitempty"`
+ LocationID *int `json:"location_id,omitempty"`
+ TemplateID *int `json:"template_id,omitempty"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DueAtStart *time.Time `json:"due_at_start,omitempty"`
+ DueAtEnd *time.Time `json:"due_at_end,omitempty"`
+ GeneratedFor *time.Time `json:"generated_for,omitempty"`
+ Recurrence string `json:"recurrence,omitempty"`
+ RecurrenceInterval int `json:"recurrence_interval"`
+ RepeatFromID *int `json:"repeat_from_id,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ CompletedBy *int `json:"completed_by,omitempty"`
+ Priority int `json:"priority"`
+ Active *bool `json:"active,omitempty"`
+ CreatedBy int `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+ Tags []string `json:"tags,omitempty"`
+ PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
+}
+
+// TaskInput contains writable fields for task creation and partial updates.
+type TaskInput struct {
+ Tags []string `json:"tags,omitempty"`
+ PlantID *int `json:"plant_id,omitempty"`
+ LocationID *int `json:"location_id,omitempty"`
+ ClearPlantID bool `json:"clear_plant_id,omitempty"`
+ ClearLocationID bool `json:"clear_location_id,omitempty"`
+ Title *string `json:"title,omitempty"`
+ Description *string `json:"description,omitempty"`
+ DueAtStart *time.Time `json:"due_at_start,omitempty"`
+ DueAtEnd *time.Time `json:"due_at_end,omitempty"`
+ ClearDueAtStart bool `json:"clear_due_at_start,omitempty"`
+ ClearDueAtEnd bool `json:"clear_due_at_end,omitempty"`
+ Recurrence *string `json:"recurrence,omitempty"`
+ RecurrenceInterval *int `json:"recurrence_interval,omitempty"`
+ Priority *int `json:"priority,omitempty"`
+ Active *bool `json:"active,omitempty"`
+ Completed *bool `json:"completed,omitempty"`
+ PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
+}
+
+// CareInstruction is garden-specific cultivation guidance 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"`
+}
+
+// CareInstructionInput contains writable care-instruction fields.
+type CareInstructionInput struct {
+ Text *string `json:"text,omitempty"`
+ Status *string `json:"status,omitempty"`
+}
+
+// JournalEntry is a garden journal or pinboard entry with related media.
+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 string `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"`
+}
+
+// JournalEntryInput contains writable journal-entry fields.
+type JournalEntryInput struct {
+ Title *string `json:"title,omitempty"`
+ Body *string `json:"body,omitempty"`
+ CreatedAt *time.Time `json:"created_at,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ EntryType *string `json:"entry_type,omitempty"`
+}
+
+// JournalAttachment describes attachment metadata without its binary body.
+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"`
+ ImageID *int `json:"image_id,omitempty"`
+}
+
+// JournalAttachmentData combines attachment metadata with its binary body.
+type JournalAttachmentData struct {
+ JournalAttachment
+ Data []byte
+}
+
+// SpeciesTaskTemplate describes a rule for generating tasks from species data.
+type SpeciesTaskTemplate struct {
+ ID int `json:"id"`
+ SpeciesID int `json:"species_id"`
+ Origin string `json:"origin"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ TriggerType string `json:"trigger_type"`
+ MonthFrom *int `json:"month_from,omitempty"`
+ DayFrom *int `json:"day_from,omitempty"`
+ MonthTo *int `json:"month_to,omitempty"`
+ DayTo *int `json:"day_to,omitempty"`
+ OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
+ OffsetDaysTo *int `json:"offset_days_to,omitempty"`
+ IntervalDays *int `json:"interval_days,omitempty"`
+ TriggerOffset int `json:"trigger_offset"`
+ TriggerOffsetUnit string `json:"trigger_offset_unit"`
+ Duration int `json:"duration"`
+ DurationUnit string `json:"duration_unit"`
+ Recurrence string `json:"recurrence,omitempty"`
+ RecurrenceInterval int `json:"recurrence_interval"`
+ Priority int `json:"priority"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Version int `json:"version"`
+}
+
+// ApplicationSettings contains application-wide lifecycle 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"`
+}
+
+// ApplicationSettingsInput contains writable lifecycle automation settings.
+type ApplicationSettingsInput struct {
+ LifecycleStatusEnabled *bool `json:"lifecycle_status_enabled,omitempty"`
+ LifecycleRemovalMonth *int `json:"lifecycle_removal_month,omitempty"`
+ LifecycleRemovalDay *int `json:"lifecycle_removal_day,omitempty"`
+ Timezone *string `json:"timezone,omitempty"`
+}
+
+// EnvironmentVariable describes one effective process configuration value.
+// Sensitive values are masked by the API before they leave the process.
+type EnvironmentVariable struct {
+ Component string `json:"component"`
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// SpeciesTaskTemplateInput contains writable task-template fields.
+type SpeciesTaskTemplateInput struct {
+ Title *string `json:"title,omitempty"`
+ Description *string `json:"description,omitempty"`
+ TriggerType *string `json:"trigger_type,omitempty"`
+ MonthFrom *int `json:"month_from,omitempty"`
+ DayFrom *int `json:"day_from,omitempty"`
+ ClearDayFrom bool `json:"clear_day_from,omitempty"`
+ MonthTo *int `json:"month_to,omitempty"`
+ DayTo *int `json:"day_to,omitempty"`
+ ClearDayTo bool `json:"clear_day_to,omitempty"`
+ OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
+ OffsetDaysTo *int `json:"offset_days_to,omitempty"`
+ IntervalDays *int `json:"interval_days,omitempty"`
+ ClearIntervalDays bool `json:"clear_interval_days,omitempty"`
+ TriggerOffset *int `json:"trigger_offset,omitempty"`
+ TriggerOffsetUnit *string `json:"trigger_offset_unit,omitempty"`
+ Duration *int `json:"duration,omitempty"`
+ DurationUnit *string `json:"duration_unit,omitempty"`
+ Recurrence *string `json:"recurrence,omitempty"`
+ RecurrenceInterval *int `json:"recurrence_interval,omitempty"`
+ Priority *int `json:"priority,omitempty"`
+ Active *bool `json:"active,omitempty"`
+}
+
+// Credentials contains email and password authentication input.
+type Credentials struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+// RegisterUserInput contains the fields required to register an account.
+type RegisterUserInput struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+// TokenInput wraps a plaintext activation or reset token.
+type TokenInput struct {
+ Token string `json:"token"`
+}
+
+// EmailInput wraps an email address for token requests.
+type EmailInput struct {
+ Email string `json:"email"`
+}
+
+// UpdatePasswordInput contains a new password and its reset token.
+type UpdatePasswordInput struct {
+ Password string `json:"password"`
+ Token string `json:"token"`
+}
+
+// AuthenticationToken is a bearer token and its expiration time.
+type AuthenticationToken struct {
+ Token string `json:"token"`
+ Expiry time.Time `json:"expiry"`
+}
+
+// Health describes API availability and build information.
+type Health struct {
+ Status string `json:"status"`
+ ServerTime time.Time `json:"server_time"`
+ SystemInfo SystemInfo `json:"system_info"`
+}
+
+// SystemInfo identifies the running API environment and version.
+type SystemInfo struct {
+ Environment string `json:"environment"`
+ Version string `json:"version"`
+}
diff --git a/lib/client/plant_locations.go b/lib/client/plant_locations.go
new file mode 100644
index 0000000..7e07577
--- /dev/null
+++ b/lib/client/plant_locations.go
@@ -0,0 +1,47 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreatePlantLocation assigns a plant to a location.
+func (c *Client) CreatePlantLocation(ctx context.Context, gardenID, plantID int, input PlantLocationInput) (PlantLocation, *Response, error) {
+ return c.writePlantLocation(ctx, http.MethodPost, plantLocationCollectionPath(gardenID, plantID), input)
+}
+
+// PlantLocations lists a plant's location assignments.
+func (c *Client) PlantLocations(ctx context.Context, gardenID, plantID int) ([]PlantLocation, *Response, error) {
+ var envelope struct {
+ PlantLocations []PlantLocation `json:"plant_locations"`
+ }
+ response, err := c.do(ctx, http.MethodGet, plantLocationCollectionPath(gardenID, plantID), nil, &envelope)
+ return envelope.PlantLocations, response, err
+}
+
+// UpdatePlantLocation updates one location assignment.
+func (c *Client) UpdatePlantLocation(ctx context.Context, gardenID, plantID, assignmentID int, input PlantLocationInput) (PlantLocation, *Response, error) {
+ return c.writePlantLocation(ctx, http.MethodPatch, plantLocationPath(gardenID, plantID, assignmentID), input)
+}
+
+// DeletePlantLocation deletes one location assignment.
+func (c *Client) DeletePlantLocation(ctx context.Context, gardenID, plantID, assignmentID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, plantLocationPath(gardenID, plantID, assignmentID), nil, nil)
+}
+
+func (c *Client) writePlantLocation(ctx context.Context, method, path string, input PlantLocationInput) (PlantLocation, *Response, error) {
+ var envelope struct {
+ PlantLocation PlantLocation `json:"plant_location"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.PlantLocation, response, err
+}
+
+func plantLocationCollectionPath(gardenID, plantID int) string {
+ return plantPath(gardenID, plantID) + "/locations"
+}
+
+func plantLocationPath(gardenID, plantID, assignmentID int) string {
+ return plantLocationCollectionPath(gardenID, plantID) + "/" + strconv.Itoa(assignmentID)
+}
diff --git a/lib/client/plants.go b/lib/client/plants.go
new file mode 100644
index 0000000..b6bcf33
--- /dev/null
+++ b/lib/client/plants.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreatePlant creates a plant instance in a garden.
+func (c *Client) CreatePlant(ctx context.Context, gardenID int, input PlantInput) (Plant, *Response, error) {
+ return c.writePlant(ctx, http.MethodPost, plantCollectionPath(gardenID), input)
+}
+
+// Plants lists all plant instances in a garden.
+func (c *Client) Plants(ctx context.Context, gardenID int) ([]Plant, *Response, error) {
+ var envelope struct {
+ Plants []Plant `json:"plants"`
+ }
+ response, err := c.do(ctx, http.MethodGet, plantCollectionPath(gardenID), nil, &envelope)
+ return envelope.Plants, response, err
+}
+
+// Plant returns one plant from a garden.
+func (c *Client) Plant(ctx context.Context, gardenID, plantID int) (Plant, *Response, error) {
+ var envelope struct {
+ Plant Plant `json:"plant"`
+ }
+ response, err := c.do(ctx, http.MethodGet, plantPath(gardenID, plantID), nil, &envelope)
+ return envelope.Plant, response, err
+}
+
+// UpdatePlant partially updates a plant in a garden.
+func (c *Client) UpdatePlant(ctx context.Context, gardenID, plantID int, input PlantInput) (Plant, *Response, error) {
+ return c.writePlant(ctx, http.MethodPatch, plantPath(gardenID, plantID), input)
+}
+
+// DeletePlant deletes a plant from a garden.
+func (c *Client) DeletePlant(ctx context.Context, gardenID, plantID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, plantPath(gardenID, plantID), nil, nil)
+}
+
+func (c *Client) writePlant(ctx context.Context, method, path string, input PlantInput) (Plant, *Response, error) {
+ var envelope struct {
+ Plant Plant `json:"plant"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Plant, response, err
+}
+
+func plantCollectionPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID) + "/plants"
+}
+
+func plantPath(gardenID, plantID int) string {
+ return plantCollectionPath(gardenID) + "/" + strconv.Itoa(plantID)
+}
diff --git a/lib/client/resources_test.go b/lib/client/resources_test.go
new file mode 100644
index 0000000..6ddcc24
--- /dev/null
+++ b/lib/client/resources_test.go
@@ -0,0 +1,83 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+)
+
+func TestSpeciesAndPlantClientPaths(t *testing.T) {
+ t.Parallel()
+
+ commonName := "Tomate"
+ plantName := "Tomate am Zaun"
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/species":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate","version":1}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/species":
+ _, _ = w.Write([]byte(`{"species":[{"id":8,"garden_id":4,"common_name":"Tomate"}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/species/8":
+ _, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/species/8":
+ _, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/species/8":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/plants":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"species_id":8,"name":"Tomate am Zaun","status":"active","version":1}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants":
+ _, _ = w.Write([]byte(`{"plants":[{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active"}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants/9":
+ _, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/plants/9":
+ _, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/plants/9":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+
+ apiClient := newTestClient(t, handler)
+ species, _, err := apiClient.CreateSpecies(context.Background(), 4, SpeciesInput{CommonName: &commonName})
+ if err != nil || species.ID != 8 {
+ t.Fatalf("CreateSpecies(): species=%+v err=%v", species, err)
+ }
+ allSpecies, _, err := apiClient.SpeciesForGarden(context.Background(), 4)
+ if err != nil || len(allSpecies) != 1 {
+ t.Fatalf("SpeciesForGarden(): species=%+v err=%v", allSpecies, err)
+ }
+ if _, _, err := apiClient.Species(context.Background(), 4, 8); err != nil {
+ t.Fatalf("Species(): %v", err)
+ }
+ updatedSpecies, _, err := apiClient.UpdateSpecies(context.Background(), 4, 8, SpeciesInput{CommonName: &commonName})
+ if err != nil || updatedSpecies.Version != 2 {
+ t.Fatalf("UpdateSpecies(): species=%+v err=%v", updatedSpecies, err)
+ }
+ if _, err := apiClient.DeleteSpecies(context.Background(), 4, 8); err != nil {
+ t.Fatalf("DeleteSpecies(): %v", err)
+ }
+
+ speciesID := 8
+ plant, _, err := apiClient.CreatePlant(context.Background(), 4, PlantInput{Name: &plantName, SpeciesID: &speciesID})
+ if err != nil || plant.ID != 9 {
+ t.Fatalf("CreatePlant(): plant=%+v err=%v", plant, err)
+ }
+ plants, _, err := apiClient.Plants(context.Background(), 4)
+ if err != nil || len(plants) != 1 {
+ t.Fatalf("Plants(): plants=%+v err=%v", plants, err)
+ }
+ if _, _, err := apiClient.Plant(context.Background(), 4, 9); err != nil {
+ t.Fatalf("Plant(): %v", err)
+ }
+ updatedPlant, _, err := apiClient.UpdatePlant(context.Background(), 4, 9, PlantInput{Name: &plantName})
+ if err != nil || updatedPlant.Version != 2 {
+ t.Fatalf("UpdatePlant(): plant=%+v err=%v", updatedPlant, err)
+ }
+ if _, err := apiClient.DeletePlant(context.Background(), 4, 9); err != nil {
+ t.Fatalf("DeletePlant(): %v", err)
+ }
+}
diff --git a/lib/client/roles.go b/lib/client/roles.go
new file mode 100644
index 0000000..b94525c
--- /dev/null
+++ b/lib/client/roles.go
@@ -0,0 +1,72 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+)
+
+// AdminRoles lists application and shared garden role templates.
+func (c *Client) AdminRoles(ctx context.Context) ([]Role, []Role, *Response, error) {
+ var envelope struct {
+ ApplicationRoles []Role `json:"application_roles"`
+ GardenRoles []Role `json:"garden_roles"`
+ }
+ response, err := c.do(ctx, http.MethodGet, "v1/admin/roles", nil, &envelope)
+ return envelope.ApplicationRoles, envelope.GardenRoles, response, err
+}
+
+// CreateAdminRole adds a shared role template.
+func (c *Client) CreateAdminRole(ctx context.Context, input RoleInput) (Role, *Response, error) {
+ var envelope struct {
+ Role Role `json:"role"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/admin/roles", input, &envelope)
+ return envelope.Role, response, err
+}
+
+// UpdateAdminRole changes a shared role template.
+func (c *Client) UpdateAdminRole(ctx context.Context, name string, input RoleInput) (Role, *Response, error) {
+ var envelope struct {
+ Role Role `json:"role"`
+ }
+ response, err := c.do(ctx, http.MethodPatch, "v1/admin/roles/"+url.PathEscape(name), input, &envelope)
+ return envelope.Role, response, err
+}
+
+// DeleteAdminRole removes an unused shared role template.
+func (c *Client) DeleteAdminRole(ctx context.Context, name string) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, "v1/admin/roles/"+url.PathEscape(name), nil, nil)
+}
+
+// GardenRoleSettings lists roles and their effective permissions in a garden.
+func (c *Client) GardenRoleSettings(ctx context.Context, gardenID int) ([]GardenRoleSetting, *Response, error) {
+ var envelope struct {
+ Roles []GardenRoleSetting `json:"roles"`
+ }
+ response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/roles", nil, &envelope)
+ return envelope.Roles, response, err
+}
+
+// UpdateGardenRoleSettings replaces a role's effective permissions in a garden.
+func (c *Client) UpdateGardenRoleSettings(ctx context.Context, gardenID int, name string, permissions []string) ([]string, *Response, error) {
+ var envelope struct {
+ EffectivePermissions []string `json:"effective_permissions"`
+ }
+ response, err := c.do(ctx, http.MethodPut, gardenPath(gardenID)+"/roles/"+url.PathEscape(name), map[string][]string{"permissions": permissions}, &envelope)
+ return envelope.EffectivePermissions, response, err
+}
+
+// CreateGardenRole adds a custom role owned by one garden.
+func (c *Client) CreateGardenRole(ctx context.Context, gardenID int, input RoleInput) (Role, *Response, error) {
+ var envelope struct {
+ Role Role `json:"role"`
+ }
+ response, err := c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/roles", input, &envelope)
+ return envelope.Role, response, err
+}
+
+// DeleteGardenRole removes an unused custom role from a garden.
+func (c *Client) DeleteGardenRole(ctx context.Context, gardenID int, name string) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/roles/"+url.PathEscape(name), nil, nil)
+}
diff --git a/lib/client/sessions.go b/lib/client/sessions.go
new file mode 100644
index 0000000..124e7f4
--- /dev/null
+++ b/lib/client/sessions.go
@@ -0,0 +1,43 @@
+package client
+
+import (
+ "context"
+ "net/http"
+)
+
+// CreateSession logs in and stores the returned cookie when the client was
+// configured with sessions. response.Cookies() can be forwarded by a web
+// frontend to its browser.
+func (c *Client) CreateSession(ctx context.Context, credentials Credentials) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/session", credentials, &envelope)
+ return envelope.User, response, err
+}
+
+// Session returns the user associated with the current session cookie.
+func (c *Client) Session(ctx context.Context) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodGet, "v1/session", nil, &envelope)
+ return envelope.User, response, err
+}
+
+// DeleteSession logs out. Forward response.Cookies() to the browser so its
+// session cookie is removed as well.
+func (c *Client) DeleteSession(ctx context.Context) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, "v1/session", nil, nil)
+}
+
+// ForwardCookies copies API Set-Cookie headers to a frontend response. It is
+// intended for the Response returned by CreateSession or DeleteSession.
+func ForwardCookies(w http.ResponseWriter, response *Response) {
+ if w == nil || response == nil || response.Response == nil {
+ return
+ }
+ for _, cookie := range response.Cookies() {
+ http.SetCookie(w, cookie)
+ }
+}
diff --git a/lib/client/species.go b/lib/client/species.go
new file mode 100644
index 0000000..36742f1
--- /dev/null
+++ b/lib/client/species.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreateSpecies creates garden-specific plant master data.
+func (c *Client) CreateSpecies(ctx context.Context, gardenID int, input SpeciesInput) (Species, *Response, error) {
+ return c.writeSpecies(ctx, http.MethodPost, speciesCollectionPath(gardenID), input)
+}
+
+// SpeciesForGarden lists global and garden-specific species available to a garden.
+func (c *Client) SpeciesForGarden(ctx context.Context, gardenID int) ([]Species, *Response, error) {
+ var envelope struct {
+ Species []Species `json:"species"`
+ }
+ response, err := c.do(ctx, http.MethodGet, speciesCollectionPath(gardenID), nil, &envelope)
+ return envelope.Species, response, err
+}
+
+// Species returns species data available to a garden.
+func (c *Client) Species(ctx context.Context, gardenID, speciesID int) (Species, *Response, error) {
+ var envelope struct {
+ Species Species `json:"species"`
+ }
+ response, err := c.do(ctx, http.MethodGet, speciesPath(gardenID, speciesID), nil, &envelope)
+ return envelope.Species, response, err
+}
+
+// UpdateSpecies partially updates garden-specific species data.
+func (c *Client) UpdateSpecies(ctx context.Context, gardenID, speciesID int, input SpeciesInput) (Species, *Response, error) {
+ return c.writeSpecies(ctx, http.MethodPatch, speciesPath(gardenID, speciesID), input)
+}
+
+// DeleteSpecies deletes garden-specific species data.
+func (c *Client) DeleteSpecies(ctx context.Context, gardenID, speciesID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, speciesPath(gardenID, speciesID), nil, nil)
+}
+
+func (c *Client) writeSpecies(ctx context.Context, method, path string, input SpeciesInput) (Species, *Response, error) {
+ var envelope struct {
+ Species Species `json:"species"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Species, response, err
+}
+
+func speciesCollectionPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID) + "/species"
+}
+
+func speciesPath(gardenID, speciesID int) string {
+ return speciesCollectionPath(gardenID) + "/" + strconv.Itoa(speciesID)
+}
diff --git a/lib/client/species_categories.go b/lib/client/species_categories.go
new file mode 100644
index 0000000..f2721c6
--- /dev/null
+++ b/lib/client/species_categories.go
@@ -0,0 +1,48 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// SpeciesCategories lists active categories available to ordinary users.
+func (c *Client) SpeciesCategories(ctx context.Context) ([]SpeciesCategory, *Response, error) {
+ return c.speciesCategories(ctx, "v1/species-categories")
+}
+
+// AdminSpeciesCategories lists all categories, including inactive ones.
+func (c *Client) AdminSpeciesCategories(ctx context.Context) ([]SpeciesCategory, *Response, error) {
+ return c.speciesCategories(ctx, "v1/admin/species-categories")
+}
+
+func (c *Client) speciesCategories(ctx context.Context, path string) ([]SpeciesCategory, *Response, error) {
+ var envelope struct {
+ Categories []SpeciesCategory `json:"categories"`
+ }
+ response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
+ return envelope.Categories, response, err
+}
+
+// CreateAdminSpeciesCategory adds an application-wide species category.
+func (c *Client) CreateAdminSpeciesCategory(ctx context.Context, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
+ return c.writeSpeciesCategory(ctx, http.MethodPost, "v1/admin/species-categories", input)
+}
+
+// UpdateAdminSpeciesCategory changes an application-wide species category.
+func (c *Client) UpdateAdminSpeciesCategory(ctx context.Context, id int, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
+ return c.writeSpeciesCategory(ctx, http.MethodPatch, "v1/admin/species-categories/"+strconv.Itoa(id), input)
+}
+
+// DeleteAdminSpeciesCategory removes an unused species category.
+func (c *Client) DeleteAdminSpeciesCategory(ctx context.Context, id int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, "v1/admin/species-categories/"+strconv.Itoa(id), nil, nil)
+}
+
+func (c *Client) writeSpeciesCategory(ctx context.Context, method, path string, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
+ var envelope struct {
+ Category SpeciesCategory `json:"category"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Category, response, err
+}
diff --git a/lib/client/species_categories_test.go b/lib/client/species_categories_test.go
new file mode 100644
index 0000000..f7e79ff
--- /dev/null
+++ b/lib/client/species_categories_test.go
@@ -0,0 +1,46 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+)
+
+func TestSpeciesCategoryClientPaths(t *testing.T) {
+ apiClient := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/species-categories":
+ _, _ = w.Write([]byte(`{"categories":[{"id":2,"name":"Gemüse","active":true}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/admin/species-categories":
+ _, _ = w.Write([]byte(`{"categories":[{"id":2,"name":"Gemüse","active":true}]}`))
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/species-categories":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"category":{"id":3,"name":"Obst","active":true}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/admin/species-categories/3":
+ _, _ = w.Write([]byte(`{"category":{"id":3,"name":"Beerenobst","active":true}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/admin/species-categories/3":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+
+ if values, _, err := apiClient.SpeciesCategories(context.Background()); err != nil || len(values) != 1 || values[0].Name != "Gemüse" {
+ t.Fatalf("list categories: values=%+v err=%v", values, err)
+ }
+ if values, _, err := apiClient.AdminSpeciesCategories(context.Background()); err != nil || len(values) != 1 {
+ t.Fatalf("admin list categories: values=%+v err=%v", values, err)
+ }
+ name := "Obst"
+ if value, _, err := apiClient.CreateAdminSpeciesCategory(context.Background(), SpeciesCategoryInput{Name: &name}); err != nil || value.ID != 3 {
+ t.Fatalf("create category: value=%+v err=%v", value, err)
+ }
+ name = "Beerenobst"
+ if value, _, err := apiClient.UpdateAdminSpeciesCategory(context.Background(), 3, SpeciesCategoryInput{Name: &name}); err != nil || value.Name != name {
+ t.Fatalf("update category: value=%+v err=%v", value, err)
+ }
+ if _, err := apiClient.DeleteAdminSpeciesCategory(context.Background(), 3); err != nil {
+ t.Fatalf("delete category: %v", err)
+ }
+}
diff --git a/lib/client/species_task_templates.go b/lib/client/species_task_templates.go
new file mode 100644
index 0000000..0a2eab2
--- /dev/null
+++ b/lib/client/species_task_templates.go
@@ -0,0 +1,53 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreateSpeciesTaskTemplate adds an automatic task rule to a species.
+func (c *Client) CreateSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID int, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
+ return c.writeSpeciesTaskTemplate(ctx, http.MethodPost, speciesTaskTemplateCollectionPath(gardenID, speciesID), input)
+}
+
+// SpeciesTaskTemplates lists task-generation rules for a species.
+func (c *Client) SpeciesTaskTemplates(ctx context.Context, gardenID, speciesID int) ([]SpeciesTaskTemplate, *Response, error) {
+ var envelope struct {
+ TaskTemplates []SpeciesTaskTemplate `json:"task_templates"`
+ }
+ response, err := c.do(ctx, http.MethodGet, speciesTaskTemplateCollectionPath(gardenID, speciesID), nil, &envelope)
+ return envelope.TaskTemplates, response, err
+}
+
+// SpeciesTaskTemplate returns one task-generation rule within its species.
+func (c *Client) SpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int) (SpeciesTaskTemplate, *Response, error) {
+ var envelope struct {
+ TaskTemplate SpeciesTaskTemplate `json:"task_template"`
+ }
+ response, err := c.do(ctx, http.MethodGet, speciesTaskTemplatePath(gardenID, speciesID, templateID), nil, &envelope)
+ return envelope.TaskTemplate, response, err
+}
+
+// UpdateSpeciesTaskTemplate changes a task-generation rule.
+func (c *Client) UpdateSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
+ return c.writeSpeciesTaskTemplate(ctx, http.MethodPatch, speciesTaskTemplatePath(gardenID, speciesID, templateID), input)
+}
+
+// DeleteSpeciesTaskTemplate removes a task-generation rule.
+func (c *Client) DeleteSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, speciesTaskTemplatePath(gardenID, speciesID, templateID), nil, nil)
+}
+func (c *Client) writeSpeciesTaskTemplate(ctx context.Context, method, path string, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
+ var envelope struct {
+ TaskTemplate SpeciesTaskTemplate `json:"task_template"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.TaskTemplate, response, err
+}
+func speciesTaskTemplateCollectionPath(gardenID, speciesID int) string {
+ return speciesPath(gardenID, speciesID) + "/task-templates"
+}
+func speciesTaskTemplatePath(gardenID, speciesID, templateID int) string {
+ return speciesTaskTemplateCollectionPath(gardenID, speciesID) + "/" + strconv.Itoa(templateID)
+}
diff --git a/lib/client/species_task_templates_test.go b/lib/client/species_task_templates_test.go
new file mode 100644
index 0000000..d3cfc2e
--- /dev/null
+++ b/lib/client/species_task_templates_test.go
@@ -0,0 +1,45 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+)
+
+func TestSpeciesTaskTemplateClientPaths(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/species/2/task-templates":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden"}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/2/task-templates":
+ _, _ = w.Write([]byte(`{"task_templates":[{"id":7,"species_id":2,"title":"Schneiden"}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
+ _, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
+ _, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ apiClient := newTestClient(t, handler)
+ title := "Schneiden"
+ if value, _, err := apiClient.CreateSpeciesTaskTemplate(context.Background(), 3, 2, SpeciesTaskTemplateInput{Title: &title}); err != nil || value.ID != 7 {
+ t.Fatalf("create: %+v %v", value, err)
+ }
+ if values, _, err := apiClient.SpeciesTaskTemplates(context.Background(), 3, 2); err != nil || len(values) != 1 {
+ t.Fatalf("list: %+v %v", values, err)
+ }
+ if _, _, err := apiClient.SpeciesTaskTemplate(context.Background(), 3, 2, 7); err != nil {
+ t.Fatal(err)
+ }
+ if value, _, err := apiClient.UpdateSpeciesTaskTemplate(context.Background(), 3, 2, 7, SpeciesTaskTemplateInput{Title: &title}); err != nil || value.Version != 2 {
+ t.Fatalf("update: %+v %v", value, err)
+ }
+ if _, err := apiClient.DeleteSpeciesTaskTemplate(context.Background(), 3, 2, 7); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/lib/client/task_priorities.go b/lib/client/task_priorities.go
new file mode 100644
index 0000000..9c4d007
--- /dev/null
+++ b/lib/client/task_priorities.go
@@ -0,0 +1,46 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// TaskPriorities lists active priority choices for task forms.
+func (c *Client) TaskPriorities(ctx context.Context) ([]TaskPriority, *Response, error) {
+ return c.taskPriorities(ctx, "v1/task-priorities")
+}
+
+// AdminTaskPriorities lists all priority catalogue entries.
+func (c *Client) AdminTaskPriorities(ctx context.Context) ([]TaskPriority, *Response, error) {
+ return c.taskPriorities(ctx, "v1/admin/task-priorities")
+}
+func (c *Client) taskPriorities(ctx context.Context, path string) ([]TaskPriority, *Response, error) {
+ var envelope struct {
+ Priorities []TaskPriority `json:"priorities"`
+ }
+ response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
+ return envelope.Priorities, response, err
+}
+
+// CreateAdminTaskPriority adds an application-wide priority choice.
+func (c *Client) CreateAdminTaskPriority(ctx context.Context, input TaskPriorityInput) (TaskPriority, *Response, error) {
+ return c.writeTaskPriority(ctx, http.MethodPost, "v1/admin/task-priorities", input)
+}
+
+// UpdateAdminTaskPriority changes an application-wide priority choice.
+func (c *Client) UpdateAdminTaskPriority(ctx context.Context, id int, input TaskPriorityInput) (TaskPriority, *Response, error) {
+ return c.writeTaskPriority(ctx, http.MethodPatch, "v1/admin/task-priorities/"+strconv.Itoa(id), input)
+}
+
+// DeleteAdminTaskPriority removes an unused priority choice.
+func (c *Client) DeleteAdminTaskPriority(ctx context.Context, id int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, "v1/admin/task-priorities/"+strconv.Itoa(id), nil, nil)
+}
+func (c *Client) writeTaskPriority(ctx context.Context, method, path string, input TaskPriorityInput) (TaskPriority, *Response, error) {
+ var envelope struct {
+ Priority TaskPriority `json:"priority"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Priority, response, err
+}
diff --git a/lib/client/task_template_opt_outs.go b/lib/client/task_template_opt_outs.go
new file mode 100644
index 0000000..c66c4a8
--- /dev/null
+++ b/lib/client/task_template_opt_outs.go
@@ -0,0 +1,25 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// TaskTemplateOptOuts lists template IDs suppressed for a plant.
+func (c *Client) TaskTemplateOptOuts(ctx context.Context, gardenID, plantID int) ([]int, *Response, error) {
+ var envelope struct {
+ TemplateIDs []int `json:"template_ids"`
+ }
+ response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/plants/"+strconv.Itoa(plantID)+"/task-template-opt-outs", nil, &envelope)
+ return envelope.TemplateIDs, response, err
+}
+
+// SetTaskTemplateOptOut enables or disables automatic generation from a template.
+func (c *Client) SetTaskTemplateOptOut(ctx context.Context, gardenID, plantID, templateID int, optedOut bool) (*Response, error) {
+ method := http.MethodPost
+ if !optedOut {
+ method = http.MethodDelete
+ }
+ return c.do(ctx, method, gardenPath(gardenID)+"/plants/"+strconv.Itoa(plantID)+"/task-template-opt-outs/"+strconv.Itoa(templateID), nil, nil)
+}
diff --git a/lib/client/tasks.go b/lib/client/tasks.go
new file mode 100644
index 0000000..9426dda
--- /dev/null
+++ b/lib/client/tasks.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// CreateTask creates a work item in a garden.
+func (c *Client) CreateTask(ctx context.Context, gardenID int, input TaskInput) (Task, *Response, error) {
+ return c.writeTask(ctx, http.MethodPost, taskCollectionPath(gardenID), input)
+}
+
+// Tasks lists work items in a garden.
+func (c *Client) Tasks(ctx context.Context, gardenID int) ([]Task, *Response, error) {
+ var envelope struct {
+ Tasks []Task `json:"tasks"`
+ }
+ response, err := c.do(ctx, http.MethodGet, taskCollectionPath(gardenID), nil, &envelope)
+ return envelope.Tasks, response, err
+}
+
+// Task returns one work item within its garden.
+func (c *Client) Task(ctx context.Context, gardenID, taskID int) (Task, *Response, error) {
+ var envelope struct {
+ Task Task `json:"task"`
+ }
+ response, err := c.do(ctx, http.MethodGet, taskPath(gardenID, taskID), nil, &envelope)
+ return envelope.Task, response, err
+}
+
+// UpdateTask partially updates a work item within its garden.
+func (c *Client) UpdateTask(ctx context.Context, gardenID, taskID int, input TaskInput) (Task, *Response, error) {
+ return c.writeTask(ctx, http.MethodPatch, taskPath(gardenID, taskID), input)
+}
+
+// DeleteTask removes a work item from its garden.
+func (c *Client) DeleteTask(ctx context.Context, gardenID, taskID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, taskPath(gardenID, taskID), nil, nil)
+}
+
+func (c *Client) writeTask(ctx context.Context, method, path string, input TaskInput) (Task, *Response, error) {
+ var envelope struct {
+ Task Task `json:"task"`
+ }
+ response, err := c.do(ctx, method, path, input, &envelope)
+ return envelope.Task, response, err
+}
+
+func taskCollectionPath(gardenID int) string {
+ return "v1/gardens/" + strconv.Itoa(gardenID) + "/tasks"
+}
+
+func taskPath(gardenID, taskID int) string {
+ return taskCollectionPath(gardenID) + "/" + strconv.Itoa(taskID)
+}
diff --git a/lib/client/tasks_test.go b/lib/client/tasks_test.go
new file mode 100644
index 0000000..7f3c3c0
--- /dev/null
+++ b/lib/client/tasks_test.go
@@ -0,0 +1,45 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "testing"
+)
+
+func TestTaskClientPaths(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/tasks":
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen"}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/tasks":
+ _, _ = w.Write([]byte(`{"tasks":[{"id":8,"garden_id":4,"title":"Gießen"}]}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/tasks/8":
+ _, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen"}}`))
+ case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/tasks/8":
+ _, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen","version":2}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/tasks/8":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ apiClient := newTestClient(t, handler)
+ title := "Gießen"
+ if task, _, err := apiClient.CreateTask(context.Background(), 4, TaskInput{Title: &title}); err != nil || task.ID != 8 {
+ t.Fatalf("CreateTask: %+v %v", task, err)
+ }
+ if tasks, _, err := apiClient.Tasks(context.Background(), 4); err != nil || len(tasks) != 1 {
+ t.Fatalf("Tasks: %+v %v", tasks, err)
+ }
+ if _, _, err := apiClient.Task(context.Background(), 4, 8); err != nil {
+ t.Fatal(err)
+ }
+ if task, _, err := apiClient.UpdateTask(context.Background(), 4, 8, TaskInput{Title: &title}); err != nil || task.Version != 2 {
+ t.Fatalf("UpdateTask: %+v %v", task, err)
+ }
+ if _, err := apiClient.DeleteTask(context.Background(), 4, 8); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/lib/client/tokens.go b/lib/client/tokens.go
new file mode 100644
index 0000000..98658b5
--- /dev/null
+++ b/lib/client/tokens.go
@@ -0,0 +1,33 @@
+package client
+
+import (
+ "context"
+ "net/http"
+)
+
+// CreateAuthenticationToken exchanges credentials for a bearer token.
+func (c *Client) CreateAuthenticationToken(ctx context.Context, credentials Credentials) (AuthenticationToken, *Response, error) {
+ var envelope struct {
+ Token AuthenticationToken `json:"authentication_token"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/tokens/authentication", credentials, &envelope)
+ return envelope.Token, response, err
+}
+
+// CreateActivationToken requests a new account activation email.
+func (c *Client) CreateActivationToken(ctx context.Context, email string) (string, *Response, error) {
+ var envelope struct {
+ Message string `json:"message"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/tokens/activation", EmailInput{Email: email}, &envelope)
+ return envelope.Message, response, err
+}
+
+// CreatePasswordResetToken requests password reset instructions for email.
+func (c *Client) CreatePasswordResetToken(ctx context.Context, email string) (string, *Response, error) {
+ var envelope struct {
+ Message string `json:"message"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/tokens/password-reset", EmailInput{Email: email}, &envelope)
+ return envelope.Message, response, err
+}
diff --git a/lib/client/users.go b/lib/client/users.go
new file mode 100644
index 0000000..4d53aca
--- /dev/null
+++ b/lib/client/users.go
@@ -0,0 +1,75 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+)
+
+// RegisterUser creates an inactive user account.
+func (c *Client) RegisterUser(ctx context.Context, input RegisterUserInput) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/users", input, &envelope)
+ return envelope.User, response, err
+}
+
+// ActivateUser activates an account using a plaintext activation token.
+func (c *Client) ActivateUser(ctx context.Context, token string) (User, *Response, error) {
+ return c.activateUser(ctx, map[string]string{"token": token})
+}
+
+// ActivateInvitedUser activates an account and sets its initial password.
+func (c *Client) ActivateInvitedUser(ctx context.Context, token, password string) (User, *Response, error) {
+ return c.activateUser(ctx, map[string]string{"token": token, "password": password})
+}
+
+func (c *Client) activateUser(ctx context.Context, input any) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodPut, "v1/users/activated", input, &envelope)
+ return envelope.User, response, err
+}
+
+// UpdatePassword replaces a password using a valid reset token.
+func (c *Client) UpdatePassword(ctx context.Context, input UpdatePasswordInput) (string, *Response, error) {
+ var envelope struct {
+ Message string `json:"message"`
+ }
+ response, err := c.do(ctx, http.MethodPut, "v1/users/password", input, &envelope)
+ return envelope.Message, response, err
+}
+
+// AdminUsers lists all non-deleted user accounts.
+func (c *Client) AdminUsers(ctx context.Context) ([]User, *Response, error) {
+ var envelope struct {
+ Users []User `json:"users"`
+ }
+ response, err := c.do(ctx, http.MethodGet, "v1/admin/users", nil, &envelope)
+ return envelope.Users, response, err
+}
+
+// InviteAdminUser creates an inactive account and sends its invitation.
+func (c *Client) InviteAdminUser(ctx context.Context, input AdminUserInviteInput) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodPost, "v1/admin/users", input, &envelope)
+ return envelope.User, response, err
+}
+
+// UpdateAdminUserRole changes an account's application role.
+func (c *Client) UpdateAdminUserRole(ctx context.Context, userID int, role string) (User, *Response, error) {
+ var envelope struct {
+ User User `json:"user"`
+ }
+ response, err := c.do(ctx, http.MethodPatch, "v1/admin/users/"+strconv.Itoa(userID), map[string]string{"role": role}, &envelope)
+ return envelope.User, response, err
+}
+
+// DeleteAdminUser permanently anonymizes and deactivates an account.
+func (c *Client) DeleteAdminUser(ctx context.Context, userID int) (*Response, error) {
+ return c.do(ctx, http.MethodDelete, "v1/admin/users/"+strconv.Itoa(userID), nil, nil)
+}
diff --git a/lib/client/users_test.go b/lib/client/users_test.go
new file mode 100644
index 0000000..dfd4821
--- /dev/null
+++ b/lib/client/users_test.go
@@ -0,0 +1,49 @@
+package client
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestAdminInvitationAndInvitedUserActivationRequests(t *testing.T) {
+ var inviteSeen, activationSeen bool
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/users":
+ var input AdminUserInviteInput
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ t.Fatal(err)
+ }
+ inviteSeen = input.Name == "Ada" && input.Email == "ada@example.com"
+ w.WriteHeader(http.StatusAccepted)
+ _, _ = w.Write([]byte(`{"user":{"id":42,"name":"Ada","email":"ada@example.com"}}`))
+ case r.Method == http.MethodPut && r.URL.Path == "/v1/users/activated":
+ var input map[string]string
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ t.Fatal(err)
+ }
+ activationSeen = input["token"] == "activation-token" && input["password"] == "new-password"
+ _, _ = w.Write([]byte(`{"user":{"id":42,"activated":true}}`))
+ case r.Method == http.MethodDelete && r.URL.Path == "/v1/admin/users/42":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ apiClient := newTestClient(t, handler)
+
+ if _, _, err := apiClient.InviteAdminUser(t.Context(), AdminUserInviteInput{Name: "Ada", Email: "ada@example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := apiClient.ActivateInvitedUser(t.Context(), "activation-token", "new-password"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := apiClient.DeleteAdminUser(t.Context(), 42); err != nil {
+ t.Fatal(err)
+ }
+ if !inviteSeen || !activationSeen {
+ t.Fatalf("request payloads not received: invite=%t activation=%t", inviteSeen, activationSeen)
+ }
+}
diff --git a/remote/production/api.service b/remote/production/api.service
new file mode 100644
index 0000000..323c4be
--- /dev/null
+++ b/remote/production/api.service
@@ -0,0 +1,34 @@
+[Unit]
+# Description is a human-readable name for the service.
+Description=Gardomatic API service
+
+# Wait until PostgreSQL is running and the network is "up" before starting the service.
+After=postgresql.service
+After=network-online.target
+Wants=network-online.target
+
+# Configure service start rate limiting. If the service is (re)started more than 5 times
+# in 600 seconds then don't permit it to start anymore.
+StartLimitIntervalSec=600
+StartLimitBurst=5
+
+[Service]
+# Execute the API binary as the gardomatic user, loading its dedicated environment file
+# and using its writable state directory.
+Type=exec
+User=gardomatic
+Group=gardomatic
+EnvironmentFile=/etc/gardomatic/gardomatic.env
+WorkingDirectory=/var/lib/gardomatic
+ExecStart=/usr/local/bin/gardomatic-api
+
+# Automatically restart the service after a 5-second wait if it exits with a non-zero
+# exit code. If it restarts more than 5 times in 600 seconds, then the rate limit we
+# configured above will be hit and it won't be restarted anymore.
+Restart=on-failure
+RestartSec=5
+
+[Install]
+# Start the service automatically at boot time (the 'multi-user.target' describes a boot
+# state when the system will accept logins).
+WantedBy=multi-user.target
diff --git a/remote/production/deploy-server.sh b/remote/production/deploy-server.sh
new file mode 100755
index 0000000..d90af43
--- /dev/null
+++ b/remote/production/deploy-server.sh
@@ -0,0 +1,66 @@
+#!/bin/bash
+set -Eeuo pipefail
+
+# Installs artifacts uploaded by `make production/deploy`. Run as root or as an
+# administrator with passwordless sudo.
+if [[ $(id -u) -ne 0 ]]; then
+ exec sudo -n "$0" "$@"
+fi
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+APPLICATION_DIR=/opt/gardomatic
+STATE_DIR=/var/lib/gardomatic
+ENV_FILE=/etc/gardomatic/gardomatic.env
+
+required_files=(
+ api
+ web
+ cli
+ api.service
+ web.service
+ create-admin.sh
+)
+for file in "${required_files[@]}"; do
+ [[ -f "$SCRIPT_DIR/$file" ]] || {
+ printf 'Missing deployment artifact: %s\n' "$file" >&2
+ exit 1
+ }
+done
+[[ -d "$SCRIPT_DIR/migrations" ]] || {
+ printf 'Missing deployment artifact: migrations\n' >&2
+ exit 1
+}
+[[ -f "$ENV_FILE" ]] || {
+ printf 'Missing runtime configuration: %s; run production/provision first.\n' "$ENV_FILE" >&2
+ exit 1
+}
+id gardomatic >/dev/null 2>&1 || {
+ printf 'Missing service user gardomatic; run production/provision first.\n' >&2
+ exit 1
+}
+
+install -d -m 0755 -o root -g root "$APPLICATION_DIR"
+install -d -m 0750 -o gardomatic -g gardomatic "$STATE_DIR"
+install -m 0755 -o root -g root "$SCRIPT_DIR/api" /usr/local/bin/gardomatic-api
+install -m 0755 -o root -g root "$SCRIPT_DIR/web" /usr/local/bin/gardomatic-web
+install -m 0755 -o root -g root "$SCRIPT_DIR/cli" /usr/local/bin/gardomatic-cli
+install -d -m 0755 -o root -g root "$APPLICATION_DIR/migrations"
+rsync --archive --delete "$SCRIPT_DIR/migrations/" "$APPLICATION_DIR/migrations/"
+
+install -m 0644 -o root -g root "$SCRIPT_DIR/api.service" /etc/systemd/system/api.service
+install -m 0644 -o root -g root "$SCRIPT_DIR/web.service" /etc/systemd/system/web.service
+install -m 0755 -o root -g root "$SCRIPT_DIR/create-admin.sh" /usr/local/sbin/gardomatic-create-admin
+
+# provision-server.sh writes a file compatible with systemd and Bash. Loading it
+# here keeps the database DSN out of command-line arguments and process listings.
+set -a
+# shellcheck disable=SC1091
+source "$ENV_FILE"
+set +a
+migrate -path "$APPLICATION_DIR/migrations" -database "$GARDOMATIC_DB_DSN" up
+
+systemctl daemon-reload
+systemctl enable api web
+systemctl restart api web
+
+printf 'Gardomatic deployment complete.\n'
diff --git a/remote/production/web.service b/remote/production/web.service
new file mode 100644
index 0000000..543f350
--- /dev/null
+++ b/remote/production/web.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Gardomatic web service
+After=network-online.target api.service
+Wants=network-online.target
+
+[Service]
+Type=exec
+User=gardomatic
+Group=gardomatic
+EnvironmentFile=/etc/gardomatic/gardomatic.env
+WorkingDirectory=/var/lib/gardomatic
+ExecStart=/usr/local/bin/gardomatic-web
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/remote/setup/.env.example b/remote/setup/.env.example
new file mode 100644
index 0000000..a31235a
--- /dev/null
+++ b/remote/setup/.env.example
@@ -0,0 +1,84 @@
+# Copy this file to .env next to provision-server.sh and restrict it to the
+# administrator. The file is sourced as Bash configuration and must be trusted.
+# cp .env.example .env
+# chmod 600 .env
+
+# Server provisioning ---------------------------------------------------------
+
+# IANA timezone accepted by timedatectl. List available values with:
+# timedatectl list-timezones
+GARDOMATIC_SETUP_TIMEZONE='Europe/Berlin'
+# golang-migrate release number without a leading "v". The script supports
+# Linux AMD64 and ARM64 release archives.
+GARDOMATIC_MIGRATE_VERSION='4.19.1'
+# Allowed exactly: true or false. false leaves rebooting to the administrator.
+GARDOMATIC_REBOOT='false'
+
+# PostgreSQL provisioning -----------------------------------------------------
+
+# Database and role names. Allowed: lowercase letters, digits and underscores;
+# the first character must be a letter or underscore.
+GARDOMATIC_DB_NAME='gardomatic'
+GARDOMATIC_DB_USER='gardomatic'
+# Required non-empty password used to create the PostgreSQL role. This separate
+# value is not installed in the service environment, but the DSN below is also a
+# secret because it normally contains the same password.
+GARDOMATIC_DB_PASSWORD=''
+
+# Application runtime ---------------------------------------------------------
+
+# Allowed: development, test, production. Keep production on public servers.
+GARDOMATIC_ENV='production'
+# Required PostgreSQL connection string. Percent-encode URI-reserved characters
+# in the password. Example form:
+# postgres://gardomatic:ENCODED_PASSWORD@localhost:5432/gardomatic?sslmode=disable
+GARDOMATIC_DB_DSN=''
+# Connection-pool limits. Open must be at least 1; idle must be between 0 and open.
+GARDOMATIC_DB_MAX_OPEN_CONNS='25'
+GARDOMATIC_DB_MAX_IDLE_CONNS='25'
+# Go duration, for example 30s, 15m or 1h30m.
+GARDOMATIC_DB_MAX_IDLE_TIME='15m'
+
+# Listener hosts. Use 127.0.0.1 behind a reverse proxy; an empty value listens on
+# all available interfaces. Ports must be integers from 1 to 65535.
+GARDOMATIC_API_HOST='127.0.0.1'
+GARDOMATIC_API_PORT='4000'
+GARDOMATIC_WEB_HOST='127.0.0.1'
+GARDOMATIC_WEB_PORT='4040'
+# Absolute internal API URL used by the web process.
+GARDOMATIC_API_BASE_URL='http://127.0.0.1:4000'
+# Required absolute public HTTP(S) URL. Use HTTPS and normally no path in production.
+GARDOMATIC_WEB_BASE_URL=''
+
+# Non-empty cookie name shared by API and web.
+GARDOMATIC_SESSION_COOKIE_NAME='gardomatic_session'
+# Go durations, for example 30m, 12h or 168h. Use positive values.
+GARDOMATIC_SESSION_LIFETIME='12h'
+GARDOMATIC_SESSION_IDLE_TIMEOUT='30m'
+# Boolean. Accepted true values: 1, t, T, TRUE, true, True. Accepted false
+# values: 0, f, F, FALSE, false, False. Must be true in production.
+GARDOMATIC_COOKIE_SECURE='true'
+
+# Boolean with the same accepted forms as GARDOMATIC_COOKIE_SECURE.
+GARDOMATIC_RATE_LIMIT_ENABLED='true'
+# Positive requests per second; decimal values such as 0.5 are accepted.
+GARDOMATIC_RATE_LIMIT_RPS='10'
+# Positive integer defining the permitted request burst.
+GARDOMATIC_RATE_LIMIT_BURST='40'
+# Comma-separated absolute URLs; every entry requires a scheme and host. Empty
+# allows no cross-origin browser access. Use origins without paths, for example:
+# https://garden.example.com,https://admin.example.com
+GARDOMATIC_CORS_TRUSTED_ORIGINS=''
+
+# Delivery mode. Allowed exactly: file or smtp. Production normally uses smtp.
+GARDOMATIC_SMTP_MODE='smtp'
+# Required in smtp mode: resolvable SMTP hostname and TCP port.
+GARDOMATIC_SMTP_HOST=''
+GARDOMATIC_SMTP_PORT='587'
+# Required non-empty credentials in smtp mode.
+GARDOMATIC_SMTP_USERNAME=''
+GARDOMATIC_SMTP_PASSWORD=''
+# Required sender mailbox in smtp mode, for example gardomatic@example.com.
+GARDOMATIC_SMTP_SENDER=''
+# Writable absolute path required only when GARDOMATIC_SMTP_MODE=file.
+GARDOMATIC_SMTP_FILE_PATH='/tmp/gardomatic-mails.log'
diff --git a/remote/setup/create-admin.sh b/remote/setup/create-admin.sh
new file mode 100755
index 0000000..adf880a
--- /dev/null
+++ b/remote/setup/create-admin.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+set -Eeuo pipefail
+
+# Creates an active application administrator and prints a generated password
+# exactly once. Installed during deployment as gardomatic-create-admin.
+if [[ $(id -u) -ne 0 ]]; then
+ exec sudo -n "$0" "$@"
+fi
+
+ENV_FILE=/etc/gardomatic/gardomatic.env
+CLI=/usr/local/bin/gardomatic-cli
+
+[[ -f "$ENV_FILE" ]] || {
+ printf 'Missing runtime configuration: %s\n' "$ENV_FILE" >&2
+ exit 1
+}
+[[ -x "$CLI" ]] || {
+ printf 'Missing Gardomatic CLI: %s\n' "$CLI" >&2
+ exit 1
+}
+
+read -r -p 'Administrator name: ' admin_name
+read -r -p 'Administrator email: ' admin_email
+[[ -n "$admin_name" && -n "$admin_email" ]] || {
+ printf 'Name and email must not be empty.\n' >&2
+ exit 1
+}
+
+set -a
+# shellcheck disable=SC1091
+source "$ENV_FILE"
+set +a
+
+"$CLI" --yes users create \
+ --name "$admin_name" \
+ --email "$admin_email" \
+ --role application:admin \
+ --active \
+ --generate-password
diff --git a/remote/setup/provision-server.sh b/remote/setup/provision-server.sh
new file mode 100755
index 0000000..fc02216
--- /dev/null
+++ b/remote/setup/provision-server.sh
@@ -0,0 +1,242 @@
+#!/bin/bash
+set -Eeuo pipefail
+
+# Run this script as root or as an administrator with passwordless sudo.
+# Configuration is read from a trusted shell-style .env file or standard input.
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+ENV_FILE="${SCRIPT_DIR}/.env"
+
+usage() {
+ cat <<'EOF'
+Usage: ./provision-server.sh [--env-file PATH|-]
+
+Provision the Gardomatic server and install its runtime configuration.
+The default configuration file is .env next to this script. Use - to stream the
+configuration over SSH without storing the source file on the server.
+EOF
+}
+
+die() {
+ printf 'Error: %s\n' "$*" >&2
+ exit 1
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --env-file)
+ (($# >= 2)) || die "--env-file requires a path"
+ ENV_FILE=$2
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "unknown argument: $1"
+ ;;
+ esac
+done
+
+if [[ "$ENV_FILE" == - ]]; then
+ ENV_SOURCE=/dev/stdin
+ ENV_LABEL='standard input'
+else
+ [[ -f "$ENV_FILE" ]] || die "configuration file not found: $ENV_FILE"
+ env_permissions=$(stat -c '%a' "$ENV_FILE")
+ (( (8#$env_permissions & 077) == 0 )) || die "$ENV_FILE must not be readable or writable by group or others (run: chmod 600 '$ENV_FILE')"
+ ENV_SOURCE=$ENV_FILE
+ ENV_LABEL=$ENV_FILE
+fi
+
+# The file is deliberately sourced so quoted values work. It must therefore be
+# controlled by the administrator running this script.
+set -a
+# shellcheck disable=SC1090
+source "$ENV_SOURCE"
+set +a
+
+require_variable() {
+ local name=$1
+ [[ -n "${!name:-}" ]] || die "required variable $name is missing in $ENV_LABEL"
+}
+
+require_identifier() {
+ local name=$1
+ local value=${!name:-}
+ [[ "$value" =~ ^[a-z_][a-z0-9_]*$ ]] || die "$name must be a lowercase PostgreSQL identifier"
+}
+
+require_variable GARDOMATIC_DB_PASSWORD
+require_variable GARDOMATIC_DB_DSN
+require_variable GARDOMATIC_WEB_BASE_URL
+require_identifier GARDOMATIC_DB_NAME
+require_identifier GARDOMATIC_DB_USER
+
+case "${GARDOMATIC_SMTP_MODE:-file}" in
+ smtp)
+ require_variable GARDOMATIC_SMTP_HOST
+ require_variable GARDOMATIC_SMTP_USERNAME
+ require_variable GARDOMATIC_SMTP_PASSWORD
+ require_variable GARDOMATIC_SMTP_SENDER
+ ;;
+ file)
+ require_variable GARDOMATIC_SMTP_FILE_PATH
+ ;;
+ *)
+ die "GARDOMATIC_SMTP_MODE must be smtp or file"
+ ;;
+esac
+
+GARDOMATIC_SETUP_TIMEZONE=${GARDOMATIC_SETUP_TIMEZONE:-Europe/Berlin}
+GARDOMATIC_MIGRATE_VERSION=${GARDOMATIC_MIGRATE_VERSION:-4.19.1}
+GARDOMATIC_REBOOT=${GARDOMATIC_REBOOT:-false}
+[[ "$GARDOMATIC_REBOOT" == true || "$GARDOMATIC_REBOOT" == false ]] || die "GARDOMATIC_REBOOT must be true or false"
+
+readonly GARDOMATIC_SERVICE_USER=gardomatic
+
+run_as_root() {
+ if [[ $(id -u) -eq 0 ]]; then
+ "$@"
+ else
+ sudo -n "$@"
+ fi
+}
+
+run_as_postgres() {
+ if [[ $(id -u) -eq 0 ]]; then
+ runuser -u postgres -- "$@"
+ else
+ sudo -n -u postgres "$@"
+ fi
+}
+
+if [[ $(id -u) -ne 0 ]]; then
+ sudo -n true || die "the SSH administrator needs passwordless sudo"
+fi
+
+# Force consistent command output while locales are being installed.
+export LC_ALL=en_US.UTF-8
+
+run_as_root apt update
+run_as_root apt install --yes software-properties-common locales curl rsync ufw
+run_as_root add-apt-repository --yes universe
+run_as_root apt update
+run_as_root timedatectl set-timezone "$GARDOMATIC_SETUP_TIMEZONE"
+run_as_root apt --yes install locales-all
+
+# Gardomatic runs under a dedicated service account. It has no login shell, SSH
+# keys, password or sudo privileges; deployments continue through the configured
+# server administrator account.
+if id "$GARDOMATIC_SERVICE_USER" >/dev/null 2>&1; then
+ if ! getent group "$GARDOMATIC_SERVICE_USER" >/dev/null 2>&1; then
+ run_as_root groupadd --system "$GARDOMATIC_SERVICE_USER"
+ fi
+ run_as_root usermod --lock --shell /usr/sbin/nologin "$GARDOMATIC_SERVICE_USER"
+ run_as_root usermod --gid "$GARDOMATIC_SERVICE_USER" "$GARDOMATIC_SERVICE_USER"
+ run_as_root deluser --quiet "$GARDOMATIC_SERVICE_USER" sudo >/dev/null 2>&1 || true
+else
+ run_as_root useradd --system --user-group --create-home --home-dir /var/lib/gardomatic \
+ --shell /usr/sbin/nologin "$GARDOMATIC_SERVICE_USER"
+fi
+run_as_root install -d -m 0750 -o gardomatic -g gardomatic /var/lib/gardomatic
+
+run_as_root ufw allow 22
+run_as_root ufw allow 4040/tcp
+run_as_root ufw --force enable
+run_as_root apt --yes install fail2ban
+
+# Install the migrate CLI for the host architecture.
+case "$(uname -m)" in
+ x86_64|amd64) migrate_arch=amd64 ;;
+ aarch64|arm64) migrate_arch=arm64 ;;
+ *) die "unsupported architecture for migrate: $(uname -m)" ;;
+esac
+
+download_dir=$(mktemp -d)
+runtime_env=$(mktemp)
+cleanup() {
+ rm -rf -- "$download_dir"
+ rm -f -- "$runtime_env"
+}
+trap cleanup EXIT
+
+migrate_archive="$download_dir/migrate.tar.gz"
+curl --fail --location --show-error \
+ "https://github.com/golang-migrate/migrate/releases/download/v${GARDOMATIC_MIGRATE_VERSION}/migrate.linux-${migrate_arch}.tar.gz" \
+ --output "$migrate_archive"
+tar -xzf "$migrate_archive" -C "$download_dir"
+run_as_root install -m 0755 "$download_dir/migrate" /usr/local/bin/migrate
+
+run_as_root apt --yes install postgresql postgresql-contrib
+
+if ! run_as_postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${GARDOMATIC_DB_NAME}'" | grep -qx 1; then
+ run_as_postgres createdb "$GARDOMATIC_DB_NAME"
+fi
+run_as_postgres psql -d "$GARDOMATIC_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS citext"
+run_as_postgres psql -d "$GARDOMATIC_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pgcrypto"
+if ! run_as_postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${GARDOMATIC_DB_USER}'" | grep -qx 1; then
+ printf '%s\n' "CREATE ROLE \"${GARDOMATIC_DB_USER}\" WITH LOGIN PASSWORD :'db_password';" | \
+ run_as_postgres psql -v db_password="$GARDOMATIC_DB_PASSWORD"
+else
+ printf '%s\n' "ALTER ROLE \"${GARDOMATIC_DB_USER}\" WITH LOGIN PASSWORD :'db_password';" | \
+ run_as_postgres psql -v db_password="$GARDOMATIC_DB_PASSWORD"
+fi
+run_as_postgres psql -c "ALTER DATABASE \"${GARDOMATIC_DB_NAME}\" OWNER TO \"${GARDOMATIC_DB_USER}\";"
+
+# Generate a dedicated systemd environment file. Setup-only values such as the
+# raw database password are intentionally not copied into the service environment.
+write_environment_variable() {
+ local name=$1
+ local value=${!name:-}
+ [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$name must not contain newlines"
+ value=${value//\\/\\\\}
+ value=${value//\"/\\\"}
+ value=${value//\$/\\$}
+ value=${value//\`/\\\`}
+ printf '%s="%s"\n' "$name" "$value" >>"$runtime_env"
+}
+
+runtime_variables=(
+ GARDOMATIC_ENV
+ GARDOMATIC_DB_DSN
+ GARDOMATIC_DB_MAX_OPEN_CONNS
+ GARDOMATIC_DB_MAX_IDLE_CONNS
+ GARDOMATIC_DB_MAX_IDLE_TIME
+ GARDOMATIC_API_HOST
+ GARDOMATIC_API_PORT
+ GARDOMATIC_WEB_HOST
+ GARDOMATIC_WEB_PORT
+ GARDOMATIC_API_BASE_URL
+ GARDOMATIC_WEB_BASE_URL
+ GARDOMATIC_SESSION_COOKIE_NAME
+ GARDOMATIC_SESSION_LIFETIME
+ GARDOMATIC_SESSION_IDLE_TIMEOUT
+ GARDOMATIC_COOKIE_SECURE
+ GARDOMATIC_RATE_LIMIT_ENABLED
+ GARDOMATIC_RATE_LIMIT_RPS
+ GARDOMATIC_RATE_LIMIT_BURST
+ GARDOMATIC_CORS_TRUSTED_ORIGINS
+ GARDOMATIC_SMTP_MODE
+ GARDOMATIC_SMTP_HOST
+ GARDOMATIC_SMTP_PORT
+ GARDOMATIC_SMTP_USERNAME
+ GARDOMATIC_SMTP_PASSWORD
+ GARDOMATIC_SMTP_SENDER
+ GARDOMATIC_SMTP_FILE_PATH
+)
+for variable in "${runtime_variables[@]}"; do
+ write_environment_variable "$variable"
+done
+
+run_as_root install -D -m 0600 -o root -g root "$runtime_env" /etc/gardomatic/gardomatic.env
+
+run_as_root apt --yes -o Dpkg::Options::="--force-confnew" upgrade
+
+printf 'Server setup complete. Runtime configuration installed at /etc/gardomatic/gardomatic.env.\n'
+if [[ "$GARDOMATIC_REBOOT" == true ]]; then
+ run_as_root reboot
+else
+ printf 'Reboot skipped. Set GARDOMATIC_REBOOT=true in %s to reboot automatically.\n' "$ENV_LABEL"
+fi