@@ -0,0 +1,118 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ApplicationSettingsModel stores global automation configuration.
|
||||
// ApplicationSettingsModel persists the singleton application configuration
|
||||
// and performs lifecycle cleanup transactionally.
|
||||
type ApplicationSettingsModel struct{ DB *sql.DB }
|
||||
|
||||
// Get returns the singleton application settings.
|
||||
func (m ApplicationSettingsModel) Get() (storage.ApplicationSettings, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var settings storage.ApplicationSettings
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
SELECT lifecycle_status_enabled, lifecycle_removal_month, lifecycle_removal_day,
|
||||
timezone, updated_at, version
|
||||
FROM application_settings WHERE singleton = true`).Scan(
|
||||
&settings.LifecycleStatusEnabled, &settings.LifecycleRemovalMonth,
|
||||
&settings.LifecycleRemovalDay, &settings.Timezone, &settings.UpdatedAt, &settings.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.ApplicationSettings{}, recordError(err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Update replaces the singleton application settings using optimistic locking.
|
||||
func (m ApplicationSettingsModel) Update(settings storage.ApplicationSettings) (storage.ApplicationSettings, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE application_settings
|
||||
SET lifecycle_status_enabled = $1, lifecycle_removal_month = $2,
|
||||
lifecycle_removal_day = $3, timezone = $4,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE singleton = true AND version = $5
|
||||
RETURNING updated_at, version`,
|
||||
settings.LifecycleStatusEnabled, settings.LifecycleRemovalMonth,
|
||||
settings.LifecycleRemovalDay, settings.Timezone, settings.Version,
|
||||
).Scan(&settings.UpdatedAt, &settings.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.ApplicationSettings{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.ApplicationSettings{}, recordError(err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// RemoveExpiredPlants closes expired annual and biennial plants together with
|
||||
// their active placements and records each status transition atomically.
|
||||
func (m ApplicationSettingsModel) RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
UPDATE plants p
|
||||
SET status = 'removed', removed_at = $1, updated_at = CURRENT_TIMESTAMP, version = p.version + 1
|
||||
FROM species s
|
||||
JOIN species_categories c ON c.id = s.category_id
|
||||
WHERE p.species_id = s.id
|
||||
AND p.status = 'alive'
|
||||
AND p.acquired_at IS NOT NULL
|
||||
AND c.lifecycle IN ('annual', 'biennial')
|
||||
AND EXTRACT(YEAR FROM $1::date)::int >=
|
||||
EXTRACT(YEAR FROM p.acquired_at)::int
|
||||
+ CASE WHEN (EXTRACT(MONTH FROM p.acquired_at)::int, EXTRACT(DAY FROM p.acquired_at)::int) >= ($2, $3) THEN 1 ELSE 0 END
|
||||
+ CASE c.lifecycle WHEN 'biennial' THEN 1 ELSE 0 END
|
||||
RETURNING p.id`, asOf, removalMonth, removalDay)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := []int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at)
|
||||
SELECT unnest($2::bigint[]), 'alive', 'removed', 'lifecycle_reached', $1`, asOf, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE plant_locations SET removed_at = $1, version = version + 1 WHERE plant_id = ANY($2) AND removed_at IS NULL`, asOf, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET active = false, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE plant_id = ANY($1) AND template_id IS NOT NULL AND completed_at IS NULL AND active = true`, pq.Array(ids)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestRemoveExpiredPlantsClosesRelatedRecordsAndWritesHistory(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("150405.000000000")
|
||||
var userID, gardenID, categoryID, biennialCategoryID, speciesID, biennialSpeciesID, plantID, biennialPlantID, locationID, templateID, taskID, manualTaskID int
|
||||
if err = db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Lifecycle integration', $1, 'hash', true) RETURNING id`, "lifecycle-"+stamp+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Lifecycle integration') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'annual') RETURNING id`, "Annual "+stamp).Scan(&categoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'biennial') RETURNING id`, "Biennial "+stamp).Scan(&biennialCategoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID)
|
||||
_, _ = db.Exec(`DELETE FROM species_categories WHERE id IN ($1, $2)`, categoryID, biennialCategoryID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Sommerblume', $2) RETURNING id`, gardenID, categoryID).Scan(&speciesID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Zweijährige Blume', $2) RETURNING id`, gardenID, biennialCategoryID).Scan(&biennialSpeciesID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, day_from) VALUES ($1, 'Pflegen', 'month_of_year', 3, 10) RETURNING id`, speciesID).Scan(&templateID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Sommerblume', '2026-03-10') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Zweijährige Blume', '2026-03-10') RETURNING id`, gardenID, biennialSpeciesID).Scan(&biennialPlantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO locations (garden_id, name) VALUES ($1, 'Beet') RETURNING id`, gardenID).Scan(&locationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`INSERT INTO plant_locations (plant_id, location_id, planted_at) VALUES ($1, $2, '2026-03-10')`, plantID, locationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, template_id, title, created_by) VALUES ($1, $2, $3, 'Pflegen', $4) RETURNING id`, gardenID, plantID, templateID, userID).Scan(&taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, title, created_by) VALUES ($1, $2, 'Dokumentieren', $3) RETURNING id`, gardenID, plantID, userID).Scan(&manualTaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
count, err := (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("remove expired plants: count=%d err=%v", count, err)
|
||||
}
|
||||
var status string
|
||||
var removedAt time.Time
|
||||
if err = db.QueryRow(`SELECT status, removed_at FROM plants WHERE id = $1`, plantID).Scan(&status, &removedAt); err != nil || status != "removed" || removedAt.Format("2006-01-02") != "2026-12-01" {
|
||||
t.Fatalf("plant status=%q removed_at=%v err=%v", status, removedAt, err)
|
||||
}
|
||||
var assignmentClosed, taskActive bool
|
||||
if err = db.QueryRow(`SELECT removed_at IS NOT NULL FROM plant_locations WHERE plant_id = $1`, plantID).Scan(&assignmentClosed); err != nil || !assignmentClosed {
|
||||
t.Fatalf("assignment closed=%v err=%v", assignmentClosed, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, taskID).Scan(&taskActive); err != nil || taskActive {
|
||||
t.Fatalf("task active=%v err=%v", taskActive, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, manualTaskID).Scan(&taskActive); err != nil || !taskActive {
|
||||
t.Fatalf("manual task active=%v err=%v", taskActive, err)
|
||||
}
|
||||
var historyCount int
|
||||
if err = db.QueryRow(`SELECT count(*) FROM plant_status_history WHERE plant_id = $1 AND reason = 'lifecycle_reached'`, plantID).Scan(&historyCount); err != nil || historyCount != 1 {
|
||||
t.Fatalf("history count=%d err=%v", historyCount, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "alive" {
|
||||
t.Fatalf("biennial plant was removed too early: status=%q err=%v", status, err)
|
||||
}
|
||||
count, err = (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2027, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("remove biennial plant: count=%d err=%v", count, err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "removed" {
|
||||
t.Fatalf("biennial plant status=%q err=%v", status, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// CareInstructionModel implements storage.CareInstructionModelInterface for
|
||||
// PostgreSQL and scopes every lookup through the owning garden.
|
||||
type CareInstructionModel struct{ DB *sql.DB }
|
||||
|
||||
const careInstructionColumns = `ci.id, ci.species_id, ci.text, ci.status, ci.created_by, ci.updated_by, ci.created_at, ci.updated_at, ci.version`
|
||||
|
||||
func scanCareInstruction(s scanner) (storage.CareInstruction, error) {
|
||||
var item storage.CareInstruction
|
||||
err := s.Scan(&item.ID, &item.SpeciesID, &item.Text, &item.Status, &item.CreatedBy, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
return item, err
|
||||
}
|
||||
|
||||
// Insert creates a care instruction.
|
||||
func (m CareInstructionModel) Insert(item storage.CareInstruction) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO care_instructions(species_id,text,status,created_by,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING id,created_at,updated_at,version`, item.SpeciesID, item.Text, item.Status, item.CreatedBy, item.UpdatedBy).Scan(&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
return item, recordError(err)
|
||||
}
|
||||
|
||||
// Get returns a care instruction within its garden and species.
|
||||
func (m CareInstructionModel) Get(gardenID, speciesID, id int) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
return scanCareInstruction(m.DB.QueryRowContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.id=$1 AND ci.species_id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, id, speciesID, gardenID))
|
||||
}
|
||||
|
||||
// GetAllForSpecies lists care instructions for a species visible in a garden.
|
||||
func (m CareInstructionModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.species_id=$1 AND (s.garden_id IS NULL OR s.garden_id=$2) ORDER BY ci.id`, speciesID, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []storage.CareInstruction{}
|
||||
for rows.Next() {
|
||||
item, e := scanCareInstruction(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a care instruction using optimistic locking.
|
||||
func (m CareInstructionModel) Update(gardenID int, item storage.CareInstruction) (storage.CareInstruction, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE care_instructions ci SET text=$1,status=$2,updated_by=$3,updated_at=now(),version=ci.version+1 FROM species s WHERE ci.species_id=s.id AND ci.id=$4 AND ci.version=$5 AND (s.garden_id IS NULL OR s.garden_id=$6) RETURNING ci.updated_at,ci.version`, item.Text, item.Status, item.UpdatedBy, item.ID, item.Version, gardenID).Scan(&item.UpdatedAt, &item.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.CareInstruction{}, storage.ErrEditConflict
|
||||
}
|
||||
return item, recordError(err)
|
||||
}
|
||||
|
||||
// Delete removes a care instruction within its garden and species.
|
||||
func (m CareInstructionModel) Delete(gardenID, speciesID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM care_instructions ci USING species s WHERE ci.species_id=s.id AND ci.species_id=$1 AND ci.id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, speciesID, id, gardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
|
||||
package postgres
|
||||
@@ -0,0 +1,126 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenInviteModel stores garden invitations in PostgreSQL.
|
||||
// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL.
|
||||
type GardenInviteModel struct{ DB *sql.DB }
|
||||
|
||||
// Upsert creates or replaces a pending invitation for a garden and email.
|
||||
func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
|
||||
invite.Token = rand.Text()
|
||||
hash := sha256.Sum256([]byte(invite.Token))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at)
|
||||
SELECT $1,$2,$3,$4,$5,$6
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET
|
||||
role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by,
|
||||
expires_at=EXCLUDED.expires_at, created_at=now()
|
||||
RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
func scanInvite(row scanner) (storage.GardenInvite, error) {
|
||||
var invite storage.GardenInvite
|
||||
err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
// GetByToken returns an unexpired pending invitation by its plaintext token.
|
||||
func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenInvite{}, recordError(err)
|
||||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists pending invitations for a garden.
|
||||
func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.GardenInvite{}
|
||||
for rows.Next() {
|
||||
invite, err := scanInvite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, invite)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Delete revokes an invitation within its garden.
|
||||
func (m GardenInviteModel) Delete(gardenID, inviteID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept consumes an invitation and creates or updates membership in one
|
||||
// transaction so a token cannot be accepted twice concurrently.
|
||||
func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role}
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = GardenMemberModel(m).loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenMemberModel stores garden memberships in PostgreSQL.
|
||||
// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
|
||||
type GardenMemberModel struct{ DB *sql.DB }
|
||||
|
||||
func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
|
||||
UNION ALL
|
||||
SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
|
||||
ORDER BY granted DESC`, member.Role, member.GardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
base := []string{}
|
||||
overrides := []storage.GardenRolePermissionOverride{}
|
||||
for rows.Next() {
|
||||
var permission storage.GardenPermission
|
||||
var granted bool
|
||||
if err := rows.Scan(&permission, &granted); err != nil {
|
||||
return err
|
||||
}
|
||||
if granted {
|
||||
base = append(base, string(permission))
|
||||
} else {
|
||||
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
member.Permissions = storage.ResolveGardenPermissions(base, overrides)
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGardenMember(s scanner) (storage.GardenMember, error) {
|
||||
var member storage.GardenMember
|
||||
err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
|
||||
return member, err
|
||||
}
|
||||
|
||||
// Insert adds a user to a garden.
|
||||
func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
SELECT $1, $2, $3
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
|
||||
).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Get returns a user's membership and effective permissions in a garden.
|
||||
func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
|
||||
SELECT garden_id, user_id, role, joined_at
|
||||
FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists garden members with effective permissions.
|
||||
func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
|
||||
FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
|
||||
ORDER BY joined_at, user_id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []storage.GardenMember{}
|
||||
for rows.Next() {
|
||||
var member storage.GardenMember
|
||||
err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, member)
|
||||
if err = m.loadPermissions(&members[len(members)-1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a member's garden role.
|
||||
func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
var current storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return storage.GardenMember{}, countErr
|
||||
} else if count == 0 {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Delete removes a non-owner membership from a garden.
|
||||
func (m GardenMemberModel) Delete(gardenID, userID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var role storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if role == storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
|
||||
return err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransferOwnership swaps owner and member roles atomically while preserving
|
||||
// the invariant that a garden always has one owner.
|
||||
func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
|
||||
if fromUserID == toUserID {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var fromRole, toRole storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if fromRole != storage.GardenRoleOwner {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenModel stores gardens in PostgreSQL.
|
||||
// GardenModel implements storage.GardenModelInterface for PostgreSQL.
|
||||
type GardenModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert creates a garden and its owner membership atomically.
|
||||
func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
INSERT INTO gardens (name, description, image_data, image_id)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, created_at, updated_at, version`
|
||||
err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan(
|
||||
&garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func scanGarden(s scanner) (storage.Garden, error) {
|
||||
var garden storage.Garden
|
||||
err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version)
|
||||
return garden, err
|
||||
}
|
||||
|
||||
// Get returns a garden by ID.
|
||||
func (m GardenModel) Get(id int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
garden, err := scanGarden(m.DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version
|
||||
FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id))
|
||||
if err != nil {
|
||||
return storage.Garden{}, recordError(err)
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// GetAllForUser lists gardens visible to a user with resolved permissions.
|
||||
func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role
|
||||
FROM gardens g
|
||||
INNER JOIN garden_members gm ON gm.garden_id = g.id
|
||||
LEFT JOIN images i ON i.id=g.image_id
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY g.name, g.id`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
gardens := []storage.Garden{}
|
||||
for rows.Next() {
|
||||
var garden storage.Garden
|
||||
err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gardens = append(gardens, garden)
|
||||
}
|
||||
return gardens, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a garden using optimistic locking.
|
||||
func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE gardens
|
||||
SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $4 AND version = $5
|
||||
RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version,
|
||||
).Scan(&garden.UpdatedAt, &garden.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Garden{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// Delete removes a garden and its dependent records.
|
||||
func (m GardenModel) Delete(id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const queryTimeout = 3 * time.Second
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func contextWithTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
func jsonValue(value json.RawMessage) json.RawMessage {
|
||||
if len(value) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func deleteByID(db *sql.DB, query string, args ...any) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordError(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
var pqError *pq.Error
|
||||
if errors.As(err, &pqError) && pqError.Code == "23505" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if errors.As(err, &pqError) && pqError.Code == "23503" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableUserID(id int) any {
|
||||
if id < 1 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image
|
||||
// retrieval is always constrained by the owning garden.
|
||||
type ImageModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert stores an image in its garden's library.
|
||||
func (m ImageModel) Insert(image storage.Image) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
sum := sha256.Sum256(image.Data)
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName,
|
||||
image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
image.Data = nil
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// Get returns an image within its garden.
|
||||
func (m ImageModel) Get(gardenID, id int) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var image storage.Image
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan(
|
||||
&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists image metadata matching filter.
|
||||
func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at
|
||||
FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.Image{}
|
||||
for rows.Next() {
|
||||
var image storage.Image
|
||||
if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, image)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CountForGarden returns the number of images in a garden library.
|
||||
func (m ImageModel) CountForGarden(gardenID int) (int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var count int
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RecordAssignment appends an entity image-change history record.
|
||||
func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error {
|
||||
if sameImage(c.PreviousImageID, c.ImageID) {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
_, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy))
|
||||
return err
|
||||
}
|
||||
|
||||
func sameImage(a, b *int) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// JournalModel implements storage.JournalModelInterface for PostgreSQL and
|
||||
// enforces the garden boundary on entries and attachments.
|
||||
type JournalModel struct{ DB *sql.DB }
|
||||
|
||||
const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version`
|
||||
|
||||
func scanJournalEntry(s scanner) (storage.JournalEntry, error) {
|
||||
var entry storage.JournalEntry
|
||||
err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// Insert creates a journal entry and its tags atomically.
|
||||
func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entry.EntryType == "" {
|
||||
entry.EntryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Get returns an entry with tags and attachment metadata within its garden.
|
||||
func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
entry.Attachments, err = m.attachments(ctx, entry.ID)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// GetAllForGarden lists entries of an optional type in a garden.
|
||||
func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entryType == "" {
|
||||
entryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []storage.JournalEntry{}
|
||||
for rows.Next() {
|
||||
entry, scanErr := scanJournalEntry(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].Attachments, err = m.attachments(ctx, entries[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Update changes an entry and its tags atomically using optimistic locking.
|
||||
func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.JournalEntry{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Delete removes an entry within its garden.
|
||||
func (m JournalModel) Delete(gardenID, id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id)
|
||||
}
|
||||
|
||||
// InsertAttachment adds inline media or a library-image link to an entry.
|
||||
func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var entryType storage.JournalEntryType
|
||||
if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
if attachment.ImageID != nil {
|
||||
image, err := ImageModel(m).Get(gardenID, *attachment.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.MediaType, attachment.Size = image.MediaType, image.Size
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = image.FileName
|
||||
}
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = "Bild"
|
||||
}
|
||||
} else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" {
|
||||
image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)})
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.ImageID = &image.ID
|
||||
}
|
||||
data := attachment.Data
|
||||
if attachment.ImageID != nil {
|
||||
data = []byte{}
|
||||
}
|
||||
size := int64(len(attachment.Data))
|
||||
if attachment.ImageID != nil {
|
||||
size = attachment.Size
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
attachment.EntryID, attachment.Size = entryID, size
|
||||
attachment.Data = nil
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// GetAttachment returns attachment metadata and data within its garden and entry.
|
||||
func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var a storage.JournalAttachment
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAttachment removes an attachment within its garden and entry.
|
||||
func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID)
|
||||
}
|
||||
|
||||
func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) {
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.JournalAttachment{}
|
||||
for rows.Next() {
|
||||
var a storage.JournalAttachment
|
||||
if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var userID, gardenID, foreignGardenID int
|
||||
if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
|
||||
model := JournalModel{DB: db}
|
||||
entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert entry: %v", err)
|
||||
}
|
||||
foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign entry: %v", err)
|
||||
}
|
||||
if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign lookup: %v", err)
|
||||
}
|
||||
pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard entry: %v", err)
|
||||
}
|
||||
|
||||
tags := TagModel{DB: db}
|
||||
storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"})
|
||||
if err != nil || len(storedTags) != 2 {
|
||||
t.Fatalf("set tags: %#v, %v", storedTags, err)
|
||||
}
|
||||
attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert attachment: %v", err)
|
||||
}
|
||||
pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard attachment: %v", err)
|
||||
}
|
||||
if pinboardAttachment.ImageID == nil {
|
||||
t.Fatal("pinboard image was not added to the shared image library")
|
||||
}
|
||||
pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID)
|
||||
if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) {
|
||||
t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err)
|
||||
}
|
||||
loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID)
|
||||
if err != nil || string(loaded.Data) != "jpeg" {
|
||||
t.Fatalf("load attachment: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign attachment lookup: %v", err)
|
||||
}
|
||||
|
||||
entry.Title = "Große Ernte"
|
||||
updated, err := model.Update(gardenID, entry)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("update entry: %#v, %v", updated, err)
|
||||
}
|
||||
listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal)
|
||||
if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" {
|
||||
t.Fatalf("list entries: %#v, %v", listed, err)
|
||||
}
|
||||
pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard)
|
||||
if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 {
|
||||
t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err)
|
||||
}
|
||||
if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil {
|
||||
t.Fatalf("delete attachment: %v", err)
|
||||
}
|
||||
if err = model.Delete(gardenID, entry.ID); err != nil {
|
||||
t.Fatalf("delete entry: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// LocationModel stores garden locations in PostgreSQL.
|
||||
// LocationModel implements storage.LocationModelInterface for PostgreSQL and
|
||||
// scopes hierarchical locations to their garden.
|
||||
type LocationModel struct{ DB *sql.DB }
|
||||
|
||||
const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm,
|
||||
l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version,
|
||||
COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)`
|
||||
|
||||
func scanLocation(s scanner) (storage.Location, error) {
|
||||
var location storage.Location
|
||||
err := s.Scan(
|
||||
&location.ID, &location.GardenID, &location.ParentID, &location.Name,
|
||||
&location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction,
|
||||
&location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy,
|
||||
)
|
||||
return location, err
|
||||
}
|
||||
|
||||
// Insert creates a location in its garden.
|
||||
func (m LocationModel) Insert(location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO locations
|
||||
(garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
location.GardenID, location.ParentID, location.Name, location.Description,
|
||||
location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy),
|
||||
).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Get returns a location within its garden.
|
||||
func (m LocationModel) Get(gardenID, id int) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.Location{}, recordError(err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists locations in a garden.
|
||||
func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+`
|
||||
FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
locations := []storage.Location{}
|
||||
for rows.Next() {
|
||||
location, err := scanLocation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations = append(locations, location)
|
||||
}
|
||||
return locations, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a location using optimistic locking.
|
||||
func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4,
|
||||
area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id = $12 AND id = $13 AND version = $14
|
||||
RETURNING updated_at, version`,
|
||||
location.ParentID, location.Name, location.Description, location.Kind,
|
||||
location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy),
|
||||
gardenID, location.ID, location.Version,
|
||||
).Scan(&location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Location{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Delete removes a location within its garden.
|
||||
func (m LocationModel) Delete(gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var gardenID, foreignGardenID int
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) })
|
||||
|
||||
locations := LocationModel{DB: db}
|
||||
local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local location: %v", err)
|
||||
}
|
||||
foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign location: %v", err)
|
||||
}
|
||||
if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
|
||||
var plantID int
|
||||
if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
t.Fatalf("list plants with planter join: %v", err)
|
||||
}
|
||||
if len(plants) != 1 || plants[0].ID != plantID {
|
||||
t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID)
|
||||
}
|
||||
assignments := PlantLocationModel{DB: db}
|
||||
created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local assignment: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("local assignment has no id")
|
||||
}
|
||||
if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
listed, err := assignments.GetAllForPlant(gardenID, plantID)
|
||||
if err != nil || len(listed) != 1 {
|
||||
t.Fatalf("list assignments: values=%+v err=%v", listed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
DROP TABLE plant_status_history;
|
||||
DROP TABLE application_settings;
|
||||
DROP TABLE journal_attachments;
|
||||
DROP TABLE journal_entry_tags;
|
||||
DROP TABLE journal_entries;
|
||||
DROP TABLE entity_image_history;
|
||||
|
||||
ALTER TABLE locations DROP COLUMN image_id;
|
||||
ALTER TABLE plants DROP COLUMN image_id;
|
||||
ALTER TABLE species DROP COLUMN image_id;
|
||||
ALTER TABLE gardens DROP COLUMN image_id;
|
||||
DROP TABLE images;
|
||||
|
||||
DROP TABLE species_tags;
|
||||
DROP TABLE plant_tags;
|
||||
DROP TABLE task_tags;
|
||||
DROP TABLE tags;
|
||||
DROP TABLE task_priorities;
|
||||
DROP TABLE task_template_opt_outs;
|
||||
DROP TABLE tasks;
|
||||
DROP TABLE plant_locations;
|
||||
DROP TABLE plants;
|
||||
DROP TABLE locations;
|
||||
DROP TABLE species_task_templates;
|
||||
DROP TABLE care_instructions;
|
||||
DROP TABLE species;
|
||||
DROP TABLE species_categories;
|
||||
DROP TABLE garden_role_permission_overrides;
|
||||
DROP TABLE garden_invites;
|
||||
DROP TABLE garden_members;
|
||||
|
||||
ALTER TABLE users DROP CONSTRAINT users_application_role_fkey;
|
||||
DROP TABLE role_permissions;
|
||||
DROP TABLE roles;
|
||||
DROP TABLE gardens;
|
||||
DROP TABLE user_email_changes;
|
||||
DROP TABLE tokens;
|
||||
DROP TABLE sessions;
|
||||
DROP TABLE users;
|
||||
|
||||
DROP TYPE task_template_origin;
|
||||
DROP TYPE task_trigger_type;
|
||||
DROP TYPE care_instruction_status;
|
||||
DROP TYPE plant_status;
|
||||
DROP TYPE plant_lifecycle;
|
||||
DROP TYPE soil_reaction;
|
||||
DROP TYPE soil_condition;
|
||||
DROP TYPE sun_exposure;
|
||||
DROP EXTENSION citext;
|
||||
@@ -0,0 +1,524 @@
|
||||
-- Extensions and domain types
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade');
|
||||
CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy');
|
||||
CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral');
|
||||
CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial');
|
||||
CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested');
|
||||
CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned');
|
||||
CREATE TYPE task_trigger_type AS ENUM (
|
||||
'month_of_year',
|
||||
'relative_to_planting',
|
||||
'relative_to_last_task',
|
||||
'relative_to_sowing',
|
||||
'relative_to_harvest',
|
||||
'relative_to_species_planting'
|
||||
);
|
||||
CREATE TYPE task_template_origin AS ENUM (
|
||||
'manual',
|
||||
'season_sowing',
|
||||
'season_planting',
|
||||
'season_harvest'
|
||||
);
|
||||
|
||||
-- Authentication and users
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
email citext NOT NULL UNIQUE,
|
||||
password_hash bytea NOT NULL,
|
||||
activated boolean NOT NULL,
|
||||
application_role text NOT NULL DEFAULT 'application:user',
|
||||
color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int],
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token text PRIMARY KEY,
|
||||
data bytea NOT NULL,
|
||||
expiry timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_expiry_idx ON sessions (expiry);
|
||||
|
||||
CREATE TABLE tokens (
|
||||
hash bytea PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expiry timestamptz NOT NULL,
|
||||
scope text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_email_changes (
|
||||
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL UNIQUE,
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Gardens, roles, and permissions
|
||||
CREATE TABLE gardens (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
name text PRIMARY KEY,
|
||||
scope text NOT NULL CHECK (scope IN ('application', 'garden')),
|
||||
label text NOT NULL,
|
||||
system boolean NOT NULL DEFAULT false,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT roles_scope_garden_check CHECK (
|
||||
(scope = 'application' AND garden_id IS NULL) OR scope = 'garden'
|
||||
)
|
||||
);
|
||||
CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
PRIMARY KEY (role_name, permission)
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_application_role_fkey
|
||||
FOREIGN KEY (application_role) REFERENCES roles(name);
|
||||
|
||||
CREATE TABLE garden_members (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (garden_id, user_id)
|
||||
);
|
||||
CREATE INDEX garden_members_user_id_idx ON garden_members (user_id);
|
||||
|
||||
CREATE TABLE garden_invites (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX garden_invites_pending_email_unique
|
||||
ON garden_invites (garden_id, email) WHERE accepted_at IS NULL;
|
||||
CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at);
|
||||
|
||||
CREATE TABLE garden_role_permission_overrides (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
granted boolean NOT NULL,
|
||||
PRIMARY KEY (garden_id, role_name, permission)
|
||||
);
|
||||
|
||||
-- Species catalogue and care instructions
|
||||
CREATE TABLE species_categories (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
lifecycle plant_lifecycle,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name));
|
||||
|
||||
CREATE TABLE species (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT,
|
||||
common_name text NOT NULL,
|
||||
cultivar text NOT NULL DEFAULT '',
|
||||
botanical_name text NOT NULL DEFAULT '',
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
winter_protection text,
|
||||
spacing_cm integer,
|
||||
height_cm integer,
|
||||
sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12),
|
||||
sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31),
|
||||
sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12),
|
||||
sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31),
|
||||
planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12),
|
||||
planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31),
|
||||
planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12),
|
||||
planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31),
|
||||
harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12),
|
||||
harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31),
|
||||
harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12),
|
||||
harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31),
|
||||
notes text NOT NULL DEFAULT '',
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_global_unique
|
||||
ON species (common_name, cultivar) WHERE garden_id IS NULL;
|
||||
CREATE UNIQUE INDEX species_garden_unique
|
||||
ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE care_instructions (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
text text NOT NULL,
|
||||
status care_instruction_status NOT NULL DEFAULT 'untested',
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
updated_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX care_instructions_species ON care_instructions(species_id);
|
||||
|
||||
CREATE TABLE species_task_templates (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
trigger_type task_trigger_type NOT NULL,
|
||||
month_from smallint CHECK (month_from BETWEEN 1 AND 12),
|
||||
day_from smallint CHECK (day_from BETWEEN 1 AND 31),
|
||||
month_to smallint CHECK (month_to BETWEEN 1 AND 12),
|
||||
day_to smallint CHECK (day_to BETWEEN 1 AND 31),
|
||||
offset_days_from integer,
|
||||
offset_days_to integer,
|
||||
interval_days smallint,
|
||||
trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0),
|
||||
trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')),
|
||||
duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0),
|
||||
duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')),
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
origin task_template_origin NOT NULL DEFAULT 'manual',
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to)
|
||||
);
|
||||
CREATE INDEX species_task_templates_species_id_idx
|
||||
ON species_task_templates (species_id) WHERE active = true;
|
||||
CREATE UNIQUE INDEX species_task_templates_derived_origin_key
|
||||
ON species_task_templates (species_id, origin) WHERE origin <> 'manual';
|
||||
|
||||
-- Locations, plants, and tasks
|
||||
CREATE TABLE locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
parent_id bigint REFERENCES locations(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
kind text NOT NULL DEFAULT '',
|
||||
area_sqm numeric(10,2),
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX locations_garden_id_idx ON locations (garden_id);
|
||||
CREATE INDEX locations_parent_id_idx ON locations (parent_id);
|
||||
|
||||
CREATE TABLE plants (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
species_id bigint REFERENCES species(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
acquired_at date,
|
||||
status plant_status NOT NULL DEFAULT 'alive',
|
||||
removed_at date,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
planted_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive';
|
||||
|
||||
CREATE TABLE plant_locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||
quantity integer NOT NULL DEFAULT 1,
|
||||
planted_at date,
|
||||
removed_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX plant_location_active
|
||||
ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL;
|
||||
CREATE INDEX plant_locations_location_id_idx
|
||||
ON plant_locations (location_id) WHERE removed_at IS NULL;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
plant_id bigint REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint REFERENCES locations(id) ON DELETE CASCADE,
|
||||
template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
due_at_start timestamptz,
|
||||
due_at_end timestamptz,
|
||||
generated_for date,
|
||||
completed_at timestamptz,
|
||||
completed_by bigint REFERENCES users(id),
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
plant_status_on_completion plant_status,
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end),
|
||||
CONSTRAINT tasks_completion_user_check CHECK (
|
||||
(completed_at IS NULL AND completed_by IS NULL) OR
|
||||
(completed_at IS NOT NULL AND completed_by IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX tasks_garden_id_due_at_end_idx
|
||||
ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_garden_id_due_at_start_idx
|
||||
ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_repeat_from_unique
|
||||
ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_template_slot_unique
|
||||
ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE task_template_opt_outs (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (plant_id, template_id)
|
||||
);
|
||||
|
||||
CREATE TABLE task_priorities (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL UNIQUE,
|
||||
value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Tags
|
||||
CREATE TABLE tags (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (garden_id, name)
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL;
|
||||
|
||||
CREATE TABLE task_tags (
|
||||
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
CREATE TABLE plant_tags (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (plant_id, tag_id)
|
||||
);
|
||||
CREATE TABLE species_tags (
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (species_id, tag_id)
|
||||
);
|
||||
|
||||
-- Image library
|
||||
CREATE TABLE images (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
file_name text NOT NULL DEFAULT '',
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
checksum text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'upload',
|
||||
created_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum);
|
||||
|
||||
ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE entity_image_history (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')),
|
||||
entity_id bigint NOT NULL,
|
||||
previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
changed_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX entity_image_history_entity_idx
|
||||
ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC);
|
||||
|
||||
-- Journal
|
||||
CREATE TABLE journal_entries (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
author_id bigint NOT NULL REFERENCES users(id),
|
||||
entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')),
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX journal_entries_garden_created_idx
|
||||
ON journal_entries(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX journal_entries_garden_type_created_idx
|
||||
ON journal_entries(garden_id, entry_type, created_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE journal_entry_tags (
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (journal_entry_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE journal_attachments (
|
||||
id bigserial PRIMARY KEY,
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
image_id bigint REFERENCES images(id) ON DELETE RESTRICT,
|
||||
file_name text NOT NULL,
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id);
|
||||
|
||||
-- Application settings and lifecycle history
|
||||
CREATE TABLE application_settings (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
lifecycle_status_enabled boolean NOT NULL DEFAULT false,
|
||||
lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12),
|
||||
lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31),
|
||||
timezone text NOT NULL DEFAULT 'Europe/Berlin',
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE plant_status_history (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
from_status plant_status NOT NULL,
|
||||
to_status plant_status NOT NULL,
|
||||
reason text NOT NULL,
|
||||
effective_at date NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX plant_status_history_plant_id_idx
|
||||
ON plant_status_history (plant_id, created_at DESC);
|
||||
|
||||
-- Initial reference data
|
||||
INSERT INTO roles (name, scope, label, system) VALUES
|
||||
('application:user', 'application', 'Nutzer', true),
|
||||
('application:admin', 'application', 'Administrator', true),
|
||||
('owner', 'garden', 'Eigentümer', true),
|
||||
('admin', 'garden', 'Administrator', true),
|
||||
('member', 'garden', 'Mitglied', true),
|
||||
('worker', 'garden', 'Mitarbeiter', true),
|
||||
('viewer', 'garden', 'Leser', true);
|
||||
|
||||
INSERT INTO role_permissions (role_name, permission) VALUES
|
||||
('application:user', 'gardens:create'),
|
||||
('application:admin', 'gardens:create'),
|
||||
('application:admin', 'global_species:write'),
|
||||
('application:admin', 'users:manage'),
|
||||
('application:admin', 'application_settings:write'),
|
||||
('application:admin', 'roles:manage'),
|
||||
('owner', '*'),
|
||||
('admin', 'garden:*'),
|
||||
('member', 'garden:read'),
|
||||
('member', 'content:write'),
|
||||
('member', 'plants:create'),
|
||||
('member', 'plants:read:own'),
|
||||
('member', 'plants:read:other'),
|
||||
('member', 'plants:update:own'),
|
||||
('member', 'plants:delete:own'),
|
||||
('member', 'locations:create'),
|
||||
('member', 'locations:read:own'),
|
||||
('member', 'locations:read:other'),
|
||||
('member', 'locations:update:own'),
|
||||
('member', 'locations:delete:own'),
|
||||
('member', 'tasks:create'),
|
||||
('member', 'tasks:read:own'),
|
||||
('member', 'tasks:read:other'),
|
||||
('member', 'tasks:update:own'),
|
||||
('member', 'tasks:delete:own'),
|
||||
('member', 'tasks:complete:own'),
|
||||
('member', 'tasks:complete:other'),
|
||||
('worker', 'garden:read'),
|
||||
('worker', 'tasks:read:own'),
|
||||
('worker', 'tasks:read:other'),
|
||||
('worker', 'tasks:complete:own'),
|
||||
('worker', 'tasks:complete:other'),
|
||||
('viewer', 'garden:read'),
|
||||
('viewer', 'plants:read:own'),
|
||||
('viewer', 'plants:read:other'),
|
||||
('viewer', 'locations:read:own'),
|
||||
('viewer', 'locations:read:other'),
|
||||
('viewer', 'tasks:read:own'),
|
||||
('viewer', 'tasks:read:other');
|
||||
|
||||
INSERT INTO task_priorities (name, value, sort_order) VALUES
|
||||
('Niedrig', -5, 10),
|
||||
('Normal', 0, 20),
|
||||
('Erhöht', 3, 30),
|
||||
('Hoch', 5, 40);
|
||||
|
||||
INSERT INTO application_settings (singleton) VALUES (true);
|
||||
|
||||
INSERT INTO species_categories (name, sort_order) VALUES
|
||||
('Gehölz', 10),
|
||||
('Gemüse', 20),
|
||||
('Kraut', 30),
|
||||
('Obst', 40),
|
||||
('Staude', 50);
|
||||
@@ -0,0 +1,33 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// New binds all PostgreSQL model implementations to db.
|
||||
func New(db *sql.DB) storage.Models {
|
||||
return storage.Models{
|
||||
ApplicationSettings: ApplicationSettingsModel{DB: db},
|
||||
Roles: RoleModel{DB: db},
|
||||
Gardens: GardenModel{DB: db},
|
||||
GardenMembers: GardenMemberModel{DB: db},
|
||||
GardenInvites: GardenInviteModel{DB: db},
|
||||
Locations: LocationModel{DB: db},
|
||||
Plants: PlantModel{DB: db},
|
||||
PlantLocations: PlantLocationModel{DB: db},
|
||||
Species: SpeciesModel{DB: db},
|
||||
CareInstructions: CareInstructionModel{DB: db},
|
||||
SpeciesCategories: SpeciesCategoryModel{DB: db},
|
||||
TaskPriorities: TaskPriorityModel{DB: db},
|
||||
SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db},
|
||||
Tasks: TaskModel{DB: db},
|
||||
Journal: JournalModel{DB: db},
|
||||
Images: ImageModel{DB: db},
|
||||
Tags: TagModel{DB: db},
|
||||
TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db},
|
||||
Tokens: TokenModel{DB: db},
|
||||
Users: UserModel{DB: db},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user