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

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