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
+36
View File
@@ -0,0 +1,36 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// ApplicationSettings contains application-wide automation settings.
type ApplicationSettings struct {
LifecycleStatusEnabled bool `json:"lifecycle_status_enabled"`
LifecycleRemovalMonth int `json:"lifecycle_removal_month"`
LifecycleRemovalDay int `json:"lifecycle_removal_day"`
Timezone string `json:"timezone"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
}
// ApplicationSettingsModelInterface persists global settings and applies lifecycle cleanup atomically.
type ApplicationSettingsModelInterface interface {
Get() (ApplicationSettings, error)
Update(settings ApplicationSettings) (ApplicationSettings, error)
RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error)
}
// ValidateApplicationSettings validates global automation configuration.
func ValidateApplicationSettings(v *validate.Validator, settings ApplicationSettings) {
v.Check(settings.LifecycleRemovalMonth >= 1 && settings.LifecycleRemovalMonth <= 12, "lifecycle_removal_month", "must be between 1 and 12")
v.Check(settings.LifecycleRemovalDay >= 1 && settings.LifecycleRemovalDay <= 31, "lifecycle_removal_day", "must be between 1 and 31")
v.Check(strings.TrimSpace(settings.Timezone) != "", "timezone", "must be provided")
if strings.TrimSpace(settings.Timezone) != "" {
_, err := time.LoadLocation(settings.Timezone)
v.Check(err == nil, "timezone", "must be a valid IANA timezone")
}
}
+37
View File
@@ -0,0 +1,37 @@
package storage
import (
"gardomatic.kleiax.de/internal/platform/validate"
"strings"
"time"
)
// CareInstructionModelInterface persists care instructions within their
// species and garden boundary.
type CareInstructionModelInterface interface {
Insert(CareInstruction) (CareInstruction, error)
Get(gardenID, speciesID, id int) (CareInstruction, error)
GetAllForSpecies(gardenID, speciesID int) ([]CareInstruction, error)
Update(gardenID int, instruction CareInstruction) (CareInstruction, error)
Delete(gardenID, speciesID, id int) error
}
// CareInstruction records garden-specific cultivation knowledge for a species.
type CareInstruction struct {
ID int `json:"id"`
SpeciesID int `json:"species_id"`
Text string `json:"text"`
Status string `json:"status"`
CreatedBy int `json:"created_by"`
UpdatedBy int `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
}
// ValidateCareInstruction applies the care-instruction input rules.
func ValidateCareInstruction(v *validate.Validator, item CareInstruction) {
v.Check(strings.TrimSpace(item.Text) != "", "text", "must be provided")
v.Check(len(item.Text) <= 10000, "text", "must not be more than 10000 bytes long")
v.Check(validate.PermittedValue(item.Status, "good", "bad", "untested", "testing", "planned"), "status", "is invalid")
}
+3
View File
@@ -0,0 +1,3 @@
// Package storage defines Gardomatic persistence models and the interfaces
// implemented by database-specific adapters.
package storage
+10
View File
@@ -0,0 +1,10 @@
package storage
import (
"errors"
)
var (
// ErrDuplicateEmail indicates that a user email is already registered.
ErrDuplicateEmail = errors.New("models: duplicate email")
)
+76
View File
@@ -0,0 +1,76 @@
//lint:file-ignore U1000 pagination helpers are retained for the upcoming filtered list endpoints
package storage
import (
"slices"
"strings"
"gardomatic.kleiax.de/internal/platform/validate"
)
// Filters contains bounded pagination and safe-list-based sorting parameters.
type Filters struct {
Page int
PageSize int
Sort string
SortSafelist []string
}
func (f Filters) sortColumn() string {
if slices.Contains(f.SortSafelist, f.Sort) {
return strings.TrimPrefix(f.Sort, "-")
}
panic("unsafe sort parameter: " + f.Sort)
}
func (f Filters) sortDirection() string {
if strings.HasPrefix(f.Sort, "-") {
return "DESC"
}
return "ASC"
}
func (f Filters) limit() int {
return f.PageSize
}
func (f Filters) offset() int {
return (f.Page - 1) * f.PageSize
}
// ValidateFilters checks pagination bounds and the requested sort field.
func ValidateFilters(v *validate.Validator, f Filters) {
v.Check(f.Page > 0, "page", "must be greater than zero")
v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million")
v.Check(f.PageSize > 0, "page_size", "must be greater than zero")
v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100")
v.Check(validate.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value")
}
// Metadata describes a page within a filtered result set.
type Metadata struct {
CurrentPage int `json:"current_page,omitzero"`
PageSize int `json:"page_size,omitzero"`
FirstPage int `json:"first_page,omitzero"`
LastPage int `json:"last_page,omitzero"`
TotalRecords int `json:"total_records,omitzero"`
}
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
if totalRecords == 0 {
return Metadata{}
}
return Metadata{
CurrentPage: page,
PageSize: pageSize,
FirstPage: 1,
LastPage: (totalRecords + pageSize - 1) / pageSize,
TotalRecords: totalRecords,
}
}
+25
View File
@@ -0,0 +1,25 @@
package storage
import "time"
// GardenInviteModelInterface persists pending garden invitations.
type GardenInviteModelInterface interface {
Upsert(invite GardenInvite) (GardenInvite, error)
GetByToken(tokenPlaintext string) (GardenInvite, error)
GetAllForGarden(gardenID int) ([]GardenInvite, error)
Delete(gardenID, inviteID int) error
Accept(tokenPlaintext string, user User) (GardenMember, error)
}
// GardenInvite grants a user identified by email a garden role.
type GardenInvite struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
Email string `json:"email"`
Role GardenRole `json:"role"`
Token string `json:"token,omitempty"`
InvitedBy int `json:"invited_by"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+202
View File
@@ -0,0 +1,202 @@
package storage
import (
"slices"
"time"
)
// GardenRole identifies a member's authorization level within one garden.
type GardenRole string
// GardenPermission identifies one garden-scoped capability.
type GardenPermission string
// Garden permission constants identify capabilities resolved by the API's
// garden authorization middleware.
const (
GardenPermissionGardenRead GardenPermission = "garden:read"
GardenPermissionGardenUpdate GardenPermission = "garden:update"
GardenPermissionGardenDelete GardenPermission = "garden:delete"
// GardenPermissionContentWrite is retained for garden content which has not
// yet been split into object-specific permissions (journal and assignments).
GardenPermissionContentWrite GardenPermission = "content:write"
GardenPermissionMembersWrite GardenPermission = "members:write"
GardenPermissionSpeciesWrite GardenPermission = "species:write"
GardenPermissionPlantCreate GardenPermission = "plants:create"
GardenPermissionPlantReadOwn GardenPermission = "plants:read:own"
GardenPermissionPlantReadOther GardenPermission = "plants:read:other"
GardenPermissionPlantUpdateOwn GardenPermission = "plants:update:own"
GardenPermissionPlantUpdateOther GardenPermission = "plants:update:other"
GardenPermissionPlantDeleteOwn GardenPermission = "plants:delete:own"
GardenPermissionPlantDeleteOther GardenPermission = "plants:delete:other"
GardenPermissionLocationCreate GardenPermission = "locations:create"
GardenPermissionLocationReadOwn GardenPermission = "locations:read:own"
GardenPermissionLocationReadOther GardenPermission = "locations:read:other"
GardenPermissionLocationUpdateOwn GardenPermission = "locations:update:own"
GardenPermissionLocationUpdateOther GardenPermission = "locations:update:other"
GardenPermissionLocationDeleteOwn GardenPermission = "locations:delete:own"
GardenPermissionLocationDeleteOther GardenPermission = "locations:delete:other"
GardenPermissionTaskCreate GardenPermission = "tasks:create"
GardenPermissionTaskReadOwn GardenPermission = "tasks:read:own"
GardenPermissionTaskReadOther GardenPermission = "tasks:read:other"
GardenPermissionTaskUpdateOwn GardenPermission = "tasks:update:own"
GardenPermissionTaskUpdateOther GardenPermission = "tasks:update:other"
GardenPermissionTaskDeleteOwn GardenPermission = "tasks:delete:own"
GardenPermissionTaskDeleteOther GardenPermission = "tasks:delete:other"
GardenPermissionTaskCompleteOwn GardenPermission = "tasks:complete:own"
GardenPermissionTaskCompleteOther GardenPermission = "tasks:complete:other"
)
const (
// GardenRoleOwner grants full control over a garden.
GardenRoleOwner GardenRole = "owner"
// GardenRoleAdmin grants administrative access without ownership.
GardenRoleAdmin GardenRole = "admin"
// GardenRoleMember grants ordinary editing access.
GardenRoleMember GardenRole = "member"
// GardenRoleViewer grants read-only access.
GardenRoleViewer GardenRole = "viewer"
// GardenRoleWorker may read and complete tasks, but cannot otherwise edit content.
GardenRoleWorker GardenRole = "worker"
)
// Can reports whether a role grants permission.
func (role GardenRole) Can(permission GardenPermission) bool {
switch role {
case GardenRoleOwner:
return validGardenPermission(permission)
case GardenRoleAdmin:
return validGardenPermission(permission) && permission != GardenPermissionGardenDelete
case GardenRoleMember:
switch permission {
case GardenPermissionGardenRead,
GardenPermissionContentWrite,
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantDeleteOwn,
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationDeleteOwn,
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskDeleteOwn, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther:
return true
}
return false
case GardenRoleViewer:
return permission == GardenPermissionGardenRead ||
permission == GardenPermissionPlantReadOwn || permission == GardenPermissionPlantReadOther ||
permission == GardenPermissionLocationReadOwn || permission == GardenPermissionLocationReadOther ||
permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther
case GardenRoleWorker:
return permission == GardenPermissionGardenRead ||
permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther ||
permission == GardenPermissionTaskCompleteOwn || permission == GardenPermissionTaskCompleteOther
default:
return false
}
}
func validGardenPermission(permission GardenPermission) bool {
switch permission {
case GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete,
GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite,
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther,
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther,
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther:
return true
}
return false
}
// ValidGardenPermission reports whether permission is a known garden-scoped
// permission or wildcard.
func ValidGardenPermission(permission string) bool {
return permission == "*" || permission == "garden:*" || validGardenPermission(GardenPermission(permission))
}
// ResolveGardenPermissions applies garden-specific grants and revocations to a
// role's base permissions. The result is deduplicated and stable-sorted.
func ResolveGardenPermissions(base []string, overrides []GardenRolePermissionOverride) []GardenPermission {
permissions := make(map[GardenPermission]bool)
apply := func(permission string, granted bool) {
if permission == "*" || permission == "garden:*" {
for _, concrete := range AllGardenPermissions() {
if permission == "garden:*" && concrete == GardenPermissionGardenDelete {
continue
}
permissions[concrete] = granted
}
return
}
permissions[GardenPermission(permission)] = granted
}
for _, permission := range base {
apply(permission, true)
}
for _, override := range overrides {
apply(override.Permission, override.Granted)
}
result := make([]GardenPermission, 0, len(permissions))
for permission, granted := range permissions {
if granted {
result = append(result, permission)
}
}
slices.Sort(result)
return result
}
// Permissions returns the concrete capabilities bundled into a garden role.
func (role GardenRole) Permissions() []GardenPermission {
permissions := []GardenPermission{}
for _, permission := range AllGardenPermissions() {
if role.Can(permission) {
permissions = append(permissions, permission)
}
}
return permissions
}
// AllGardenPermissions returns every concrete garden-scoped permission.
func AllGardenPermissions() []GardenPermission {
return []GardenPermission{
GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete,
GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite,
GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther,
GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther,
GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther,
}
}
// GardenMemberModelInterface persists garden membership and role assignments.
type GardenMemberModelInterface interface {
Insert(member GardenMember) (GardenMember, error)
Get(gardenID, userID int) (GardenMember, error)
GetAllForGarden(gardenID int) ([]GardenMember, error)
Update(member GardenMember) (GardenMember, error)
Delete(gardenID, userID int) error
TransferOwnership(gardenID, fromUserID, toUserID int) error
}
// GardenMember links a user to a garden with a role.
type GardenMember struct {
GardenID int `json:"garden_id"`
UserID int `json:"user_id"`
Role GardenRole `json:"role"`
JoinedAt time.Time `json:"joined_at"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Permissions []GardenPermission `json:"permissions,omitempty"`
}
// Can uses persisted permissions when present, retaining built-in roles for
// compatibility with in-memory tests and pre-migration callers.
func (member GardenMember) Can(permission GardenPermission) bool {
if member.Permissions == nil {
return member.Role.Can(permission)
}
for _, granted := range member.Permissions {
if granted == permission || granted == "*" || granted == "garden:*" && permission != GardenPermissionGardenDelete {
return true
}
}
return false
}
+38
View File
@@ -0,0 +1,38 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// GardenModelInterface persists gardens and their initial owner membership.
type GardenModelInterface interface {
Insert(garden Garden, ownerID int) (Garden, error)
Get(id int) (Garden, error)
GetAllForUser(userID int) ([]Garden, error)
Update(garden Garden) (Garden, error)
Delete(id int) error
}
// Garden is the tenant boundary for garden-specific resources.
type Garden struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ImageData string `json:"image_data,omitempty"`
ImageID *int `json:"image_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Role GardenRole `json:"role,omitempty"`
Permissions []GardenPermission `json:"permissions"`
}
// ValidateGarden applies persistence-independent garden validation rules.
func ValidateGarden(v *validate.Validator, garden Garden) {
v.Check(strings.TrimSpace(garden.Name) != "", "name", "must be provided")
v.Check(len(garden.Name) <= 500, "name", "must not be more than 500 bytes long")
v.Check(len(garden.Description) <= 5000, "description", "must not be more than 5000 bytes long")
}
+44
View File
@@ -0,0 +1,44 @@
package storage
import "time"
// MaxImageSize is the largest image payload accepted by storage, in bytes.
const MaxImageSize = 10 << 20
// ImageModelInterface persists garden-owned image data and assignment history.
type ImageModelInterface interface {
Insert(image Image) (Image, error)
Get(gardenID, id int) (Image, error)
GetAllForGarden(gardenID int, filter ImageFilter) ([]Image, error)
CountForGarden(gardenID int) (int, error)
RecordAssignment(change ImageAssignment) error
}
// Image is a binary image stored in a garden's reusable media library.
type Image struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
FileName string `json:"file_name"`
MediaType string `json:"media_type"`
Size int64 `json:"size"`
Source string `json:"source"`
CreatedBy int `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
Data []byte `json:"-"`
}
// ImageFilter restricts image-library queries by filename or media type.
type ImageFilter struct {
Source string
Query string
}
// ImageAssignment records an image change on a garden entity.
type ImageAssignment struct {
GardenID int
EntityType string
EntityID int
PreviousImageID *int
ImageID *int
ChangedBy int
}
+77
View File
@@ -0,0 +1,77 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// MaxJournalAttachmentSize is the largest journal attachment accepted, in bytes.
const MaxJournalAttachmentSize = 25 << 20
// JournalModelInterface persists journal entries and their attachments.
type JournalModelInterface interface {
Insert(entry JournalEntry) (JournalEntry, error)
Get(gardenID, id int) (JournalEntry, error)
GetAllForGarden(gardenID int, entryType JournalEntryType) ([]JournalEntry, error)
Update(gardenID int, entry JournalEntry) (JournalEntry, error)
Delete(gardenID, id int) error
InsertAttachment(gardenID, entryID int, attachment JournalAttachment) (JournalAttachment, error)
GetAttachment(gardenID, entryID, attachmentID int) (JournalAttachment, error)
DeleteAttachment(gardenID, entryID, attachmentID int) error
}
// JournalEntryType distinguishes chronological journal entries from pinboard
// notes while sharing the same persistence model.
type JournalEntryType string
// Supported journal entry types.
const (
JournalEntryTypeJournal JournalEntryType = "journal"
JournalEntryTypePinboard JournalEntryType = "pinboard"
)
// JournalEntry is a garden note with optional tags and attachments.
type JournalEntry struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
AuthorID int `json:"author_id"`
AuthorName string `json:"author_name"`
AuthorColor string `json:"author_color"`
EntryType JournalEntryType `json:"entry_type"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Tags []string `json:"tags,omitempty"`
Attachments []JournalAttachment `json:"attachments,omitempty"`
}
// JournalAttachment contains either inline binary data or a reference to a
// reusable image-library item.
type JournalAttachment struct {
ID int `json:"id"`
EntryID int `json:"entry_id"`
FileName string `json:"file_name"`
MediaType string `json:"media_type"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
Data []byte `json:"-"`
ImageID *int `json:"image_id,omitempty"`
}
// ValidateJournalEntry applies the journal-entry input rules.
func ValidateJournalEntry(v *validate.Validator, entry JournalEntry) {
entryType := entry.EntryType
if entryType == "" {
entryType = JournalEntryTypeJournal
}
v.Check(validate.PermittedValue(entryType, JournalEntryTypeJournal, JournalEntryTypePinboard), "entry_type", "must be journal or pinboard")
if entryType == JournalEntryTypeJournal {
v.Check(strings.TrimSpace(entry.Title) != "", "title", "must be provided")
}
v.Check(len(entry.Title) <= 500, "title", "must not be more than 500 bytes long")
v.Check(len(entry.Body) <= 100_000, "body", "must not be more than 100000 bytes long")
}
+22
View File
@@ -0,0 +1,22 @@
package storage
import (
"strings"
"testing"
"gardomatic.kleiax.de/internal/platform/validate"
)
func TestValidateJournalEntry(t *testing.T) {
valid := validate.New()
ValidateJournalEntry(valid, JournalEntry{Title: "Erste Ernte", Body: "**Drei** Tomaten geerntet."})
if !valid.Valid() {
t.Fatalf("valid entry rejected: %#v", valid.Errors)
}
invalid := validate.New()
ValidateJournalEntry(invalid, JournalEntry{Title: " ", Body: strings.Repeat("x", 100_001)})
if invalid.Errors["title"] == "" || invalid.Errors["body"] == "" {
t.Fatalf("expected title and body errors, got %#v", invalid.Errors)
}
}
+59
View File
@@ -0,0 +1,59 @@
package storage
import (
"encoding/json"
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// LocationModelInterface persists hierarchical locations within a garden.
type LocationModelInterface interface {
Insert(location Location) (Location, error)
Get(gardenID, id int) (Location, error)
GetAllForGarden(gardenID int) ([]Location, error)
Update(gardenID int, location Location) (Location, error)
Delete(gardenID, id int) error
}
// ValidateLocation applies persistence-independent location validation rules.
func ValidateLocation(v *validate.Validator, location Location) {
v.Check(strings.TrimSpace(location.Name) != "", "name", "must be provided")
v.Check(len(location.Name) <= 500, "name", "must not be more than 500 bytes long")
v.Check(len(location.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
v.Check(len(location.Kind) <= 100, "kind", "must not be more than 100 bytes long")
v.Check(len(location.Attributes) == 0 || json.Valid(location.Attributes), "attributes", "must be valid JSON")
if location.ParentID != nil {
v.Check(*location.ParentID > 0, "parent_id", "must be a positive integer")
v.Check(*location.ParentID != location.ID, "parent_id", "must not refer to the location itself")
}
if location.AreaSQM != nil {
v.Check(*location.AreaSQM >= 0, "area_sqm", "must be zero or greater")
}
validateOptionalEnum(v, "sun_exposure", location.SunExposure, "sunny", "partial_shade", "shade")
validateOptionalEnum(v, "soil_condition", location.SoilCondition, "dry", "moist", "boggy")
validateOptionalEnum(v, "soil_reaction", location.SoilReaction, "alkaline", "acidic", "neutral")
}
// Location describes a physical place where plants can be assigned.
type Location struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
ParentID *int `json:"parent_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
ImageData string `json:"image_data,omitempty"`
ImageID *int `json:"image_id,omitempty"`
Kind string `json:"kind"`
AreaSQM *float64 `json:"area_sqm,omitempty"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
Attributes json.RawMessage `json:"attributes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
CreatedBy int `json:"created_by"`
UpdatedBy int `json:"updated_by"`
}
+38
View File
@@ -0,0 +1,38 @@
package storage
import (
"errors"
)
var (
// ErrRecordNotFound indicates that a scoped query matched no record.
ErrRecordNotFound = errors.New("record not found")
// ErrEditConflict indicates a failed optimistic-lock update.
ErrEditConflict = errors.New("edit conflict")
// ErrConflict indicates a uniqueness or equivalent persistence conflict.
ErrConflict = errors.New("conflict")
)
// Models groups the persistence interfaces required by the API application.
type Models struct {
ApplicationSettings ApplicationSettingsModelInterface
Roles RoleModelInterface
Gardens GardenModelInterface
GardenMembers GardenMemberModelInterface
GardenInvites GardenInviteModelInterface
Locations LocationModelInterface
Plants PlantModelInterface
PlantLocations PlantLocationModelInterface
Species SpeciesModelInterface
CareInstructions CareInstructionModelInterface
SpeciesCategories SpeciesCategoryModelInterface
TaskPriorities TaskPriorityModelInterface
SpeciesTaskTemplates SpeciesTaskTemplateModelInterface
Tasks TaskModelInterface
Journal JournalModelInterface
Images ImageModelInterface
Tags TagModelInterface
TaskTemplateOptOuts TaskTemplateOptOutModelInterface
Tokens TokenModelInterface
Users UserModelInterface
}
+42
View File
@@ -0,0 +1,42 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// PlantLocationModelInterface persists assignments between plants and locations.
type PlantLocationModelInterface interface {
Insert(gardenID int, plantLocation PlantLocation) (PlantLocation, error)
Get(gardenID, id int) (PlantLocation, error)
GetAllForPlant(gardenID, plantID int) ([]PlantLocation, error)
GetAllForLocation(gardenID, locationID int) ([]PlantLocation, error)
Update(gardenID int, plantLocation PlantLocation) (PlantLocation, error)
Delete(gardenID, id int) error
}
// PlantLocation records where and when a quantity of plants was planted.
type PlantLocation struct {
ID int `json:"id"`
PlantID int `json:"plant_id"`
LocationID int `json:"location_id"`
Quantity int `json:"quantity"`
PlantedAt *time.Time `json:"planted_at,omitempty"`
RemovedAt *time.Time `json:"removed_at,omitempty"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
Version int `json:"version"`
}
// ValidatePlantLocation applies persistence-independent assignment validation rules.
func ValidatePlantLocation(v *validate.Validator, assignment PlantLocation) {
v.Check(assignment.PlantID > 0, "plant_id", "must be a positive integer")
v.Check(assignment.LocationID > 0, "location_id", "must be a positive integer")
v.Check(assignment.Quantity > 0, "quantity", "must be greater than zero")
v.Check(len(strings.TrimSpace(assignment.Notes)) <= 10_000, "notes", "must not be more than 10000 bytes long")
if assignment.PlantedAt != nil && assignment.RemovedAt != nil {
v.Check(!assignment.RemovedAt.Before(*assignment.PlantedAt), "removed_at", "must not be before planted_at")
}
}
+54
View File
@@ -0,0 +1,54 @@
package storage
import (
"encoding/json"
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// PlantModelInterface persists plant instances within a garden boundary.
type PlantModelInterface interface {
Insert(plant Plant) (Plant, error)
Get(gardenID, id int) (Plant, error)
GetAllForGarden(gardenID int) ([]Plant, error)
Update(gardenID int, plant Plant) (Plant, error)
Delete(gardenID, id int) error
}
// Plant represents a named plant instance managed by a garden.
type Plant struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
SpeciesID *int `json:"species_id,omitempty"`
Name string `json:"name"`
Notes string `json:"notes"`
ImageData string `json:"image_data,omitempty"`
ImageID *int `json:"image_id,omitempty"`
AcquiredAt *time.Time `json:"acquired_at,omitempty"`
Status string `json:"status"`
RemovedAt *time.Time `json:"removed_at,omitempty"`
Attributes json.RawMessage `json:"attributes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Tags []string `json:"tags,omitempty"`
CreatedBy int `json:"created_by"`
UpdatedBy int `json:"updated_by"`
PlantedBy int `json:"planted_by"`
PlantedByName string `json:"planted_by_name,omitempty"`
}
// ValidatePlant applies persistence-independent plant validation rules.
func ValidatePlant(v *validate.Validator, plant Plant) {
v.Check(strings.TrimSpace(plant.Name) != "", "name", "must be provided")
v.Check(len(plant.Name) <= 500, "name", "must not be more than 500 bytes long")
v.Check(len(plant.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long")
v.Check(validate.PermittedValue(plant.Status, "alive", "dead", "removed", "infested", "harvested"), "status", "must be alive, dead, removed, infested or harvested")
v.Check(len(plant.Attributes) == 0 || json.Valid(plant.Attributes), "attributes", "must be valid JSON")
ValidateTags(v, plant.Tags)
if plant.SpeciesID != nil {
v.Check(*plant.SpeciesID > 0, "species_id", "must be a positive integer")
}
}
@@ -0,0 +1,118 @@
package postgres
import (
"database/sql"
"time"
"gardomatic.kleiax.de/internal/storage"
"github.com/lib/pq"
)
// ApplicationSettingsModel stores global automation configuration.
// ApplicationSettingsModel persists the singleton application configuration
// and performs lifecycle cleanup transactionally.
type ApplicationSettingsModel struct{ DB *sql.DB }
// Get returns the singleton application settings.
func (m ApplicationSettingsModel) Get() (storage.ApplicationSettings, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
var settings storage.ApplicationSettings
err := m.DB.QueryRowContext(ctx, `
SELECT lifecycle_status_enabled, lifecycle_removal_month, lifecycle_removal_day,
timezone, updated_at, version
FROM application_settings WHERE singleton = true`).Scan(
&settings.LifecycleStatusEnabled, &settings.LifecycleRemovalMonth,
&settings.LifecycleRemovalDay, &settings.Timezone, &settings.UpdatedAt, &settings.Version,
)
if err != nil {
return storage.ApplicationSettings{}, recordError(err)
}
return settings, nil
}
// Update replaces the singleton application settings using optimistic locking.
func (m ApplicationSettingsModel) Update(settings storage.ApplicationSettings) (storage.ApplicationSettings, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
UPDATE application_settings
SET lifecycle_status_enabled = $1, lifecycle_removal_month = $2,
lifecycle_removal_day = $3, timezone = $4,
updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE singleton = true AND version = $5
RETURNING updated_at, version`,
settings.LifecycleStatusEnabled, settings.LifecycleRemovalMonth,
settings.LifecycleRemovalDay, settings.Timezone, settings.Version,
).Scan(&settings.UpdatedAt, &settings.Version)
if err == sql.ErrNoRows {
return storage.ApplicationSettings{}, storage.ErrEditConflict
}
if err != nil {
return storage.ApplicationSettings{}, recordError(err)
}
return settings, nil
}
// RemoveExpiredPlants closes expired annual and biennial plants together with
// their active placements and records each status transition atomically.
func (m ApplicationSettingsModel) RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `
UPDATE plants p
SET status = 'removed', removed_at = $1, updated_at = CURRENT_TIMESTAMP, version = p.version + 1
FROM species s
JOIN species_categories c ON c.id = s.category_id
WHERE p.species_id = s.id
AND p.status = 'alive'
AND p.acquired_at IS NOT NULL
AND c.lifecycle IN ('annual', 'biennial')
AND EXTRACT(YEAR FROM $1::date)::int >=
EXTRACT(YEAR FROM p.acquired_at)::int
+ CASE WHEN (EXTRACT(MONTH FROM p.acquired_at)::int, EXTRACT(DAY FROM p.acquired_at)::int) >= ($2, $3) THEN 1 ELSE 0 END
+ CASE c.lifecycle WHEN 'biennial' THEN 1 ELSE 0 END
RETURNING p.id`, asOf, removalMonth, removalDay)
if err != nil {
return 0, err
}
ids := []int64{}
for rows.Next() {
var id int64
if err = rows.Scan(&id); err != nil {
rows.Close()
return 0, err
}
ids = append(ids, id)
}
if err = rows.Close(); err != nil {
return 0, err
}
if len(ids) == 0 {
if err = tx.Commit(); err != nil {
return 0, err
}
return 0, nil
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at)
SELECT unnest($2::bigint[]), 'alive', 'removed', 'lifecycle_reached', $1`, asOf, pq.Array(ids)); err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx, `UPDATE plant_locations SET removed_at = $1, version = version + 1 WHERE plant_id = ANY($2) AND removed_at IS NULL`, asOf, pq.Array(ids)); err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET active = false, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE plant_id = ANY($1) AND template_id IS NOT NULL AND completed_at IS NULL AND active = true`, pq.Array(ids)); err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
return len(ids), nil
}
@@ -0,0 +1,106 @@
package postgres
import (
"database/sql"
"os"
"testing"
"time"
_ "github.com/lib/pq"
)
func TestRemoveExpiredPlantsClosesRelatedRecordsAndWritesHistory(t *testing.T) {
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
if dsn == "" {
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err = db.Ping(); err != nil {
t.Fatal(err)
}
stamp := time.Now().Format("150405.000000000")
var userID, gardenID, categoryID, biennialCategoryID, speciesID, biennialSpeciesID, plantID, biennialPlantID, locationID, templateID, taskID, manualTaskID int
if err = db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Lifecycle integration', $1, 'hash', true) RETURNING id`, "lifecycle-"+stamp+"@example.com").Scan(&userID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Lifecycle integration') RETURNING id`).Scan(&gardenID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'annual') RETURNING id`, "Annual "+stamp).Scan(&categoryID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'biennial') RETURNING id`, "Biennial "+stamp).Scan(&biennialCategoryID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID)
_, _ = db.Exec(`DELETE FROM species_categories WHERE id IN ($1, $2)`, categoryID, biennialCategoryID)
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
})
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Sommerblume', $2) RETURNING id`, gardenID, categoryID).Scan(&speciesID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Zweijährige Blume', $2) RETURNING id`, gardenID, biennialCategoryID).Scan(&biennialSpeciesID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, day_from) VALUES ($1, 'Pflegen', 'month_of_year', 3, 10) RETURNING id`, speciesID).Scan(&templateID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Sommerblume', '2026-03-10') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Zweijährige Blume', '2026-03-10') RETURNING id`, gardenID, biennialSpeciesID).Scan(&biennialPlantID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO locations (garden_id, name) VALUES ($1, 'Beet') RETURNING id`, gardenID).Scan(&locationID); err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`INSERT INTO plant_locations (plant_id, location_id, planted_at) VALUES ($1, $2, '2026-03-10')`, plantID, locationID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, template_id, title, created_by) VALUES ($1, $2, $3, 'Pflegen', $4) RETURNING id`, gardenID, plantID, templateID, userID).Scan(&taskID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, title, created_by) VALUES ($1, $2, 'Dokumentieren', $3) RETURNING id`, gardenID, plantID, userID).Scan(&manualTaskID); err != nil {
t.Fatal(err)
}
count, err := (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
if err != nil || count != 1 {
t.Fatalf("remove expired plants: count=%d err=%v", count, err)
}
var status string
var removedAt time.Time
if err = db.QueryRow(`SELECT status, removed_at FROM plants WHERE id = $1`, plantID).Scan(&status, &removedAt); err != nil || status != "removed" || removedAt.Format("2006-01-02") != "2026-12-01" {
t.Fatalf("plant status=%q removed_at=%v err=%v", status, removedAt, err)
}
var assignmentClosed, taskActive bool
if err = db.QueryRow(`SELECT removed_at IS NOT NULL FROM plant_locations WHERE plant_id = $1`, plantID).Scan(&assignmentClosed); err != nil || !assignmentClosed {
t.Fatalf("assignment closed=%v err=%v", assignmentClosed, err)
}
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, taskID).Scan(&taskActive); err != nil || taskActive {
t.Fatalf("task active=%v err=%v", taskActive, err)
}
if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, manualTaskID).Scan(&taskActive); err != nil || !taskActive {
t.Fatalf("manual task active=%v err=%v", taskActive, err)
}
var historyCount int
if err = db.QueryRow(`SELECT count(*) FROM plant_status_history WHERE plant_id = $1 AND reason = 'lifecycle_reached'`, plantID).Scan(&historyCount); err != nil || historyCount != 1 {
t.Fatalf("history count=%d err=%v", historyCount, err)
}
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "alive" {
t.Fatalf("biennial plant was removed too early: status=%q err=%v", status, err)
}
count, err = (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2027, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1)
if err != nil || count != 1 {
t.Fatalf("remove biennial plant: count=%d err=%v", count, err)
}
if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "removed" {
t.Fatalf("biennial plant status=%q err=%v", status, err)
}
}
@@ -0,0 +1,82 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// CareInstructionModel implements storage.CareInstructionModelInterface for
// PostgreSQL and scopes every lookup through the owning garden.
type CareInstructionModel struct{ DB *sql.DB }
const careInstructionColumns = `ci.id, ci.species_id, ci.text, ci.status, ci.created_by, ci.updated_by, ci.created_at, ci.updated_at, ci.version`
func scanCareInstruction(s scanner) (storage.CareInstruction, error) {
var item storage.CareInstruction
err := s.Scan(&item.ID, &item.SpeciesID, &item.Text, &item.Status, &item.CreatedBy, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt, &item.Version)
return item, err
}
// Insert creates a care instruction.
func (m CareInstructionModel) Insert(item storage.CareInstruction) (storage.CareInstruction, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `INSERT INTO care_instructions(species_id,text,status,created_by,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING id,created_at,updated_at,version`, item.SpeciesID, item.Text, item.Status, item.CreatedBy, item.UpdatedBy).Scan(&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.Version)
return item, recordError(err)
}
// Get returns a care instruction within its garden and species.
func (m CareInstructionModel) Get(gardenID, speciesID, id int) (storage.CareInstruction, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
return scanCareInstruction(m.DB.QueryRowContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.id=$1 AND ci.species_id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, id, speciesID, gardenID))
}
// GetAllForSpecies lists care instructions for a species visible in a garden.
func (m CareInstructionModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.CareInstruction, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.species_id=$1 AND (s.garden_id IS NULL OR s.garden_id=$2) ORDER BY ci.id`, speciesID, gardenID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []storage.CareInstruction{}
for rows.Next() {
item, e := scanCareInstruction(rows)
if e != nil {
return nil, e
}
items = append(items, item)
}
return items, rows.Err()
}
// Update changes a care instruction using optimistic locking.
func (m CareInstructionModel) Update(gardenID int, item storage.CareInstruction) (storage.CareInstruction, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `UPDATE care_instructions ci SET text=$1,status=$2,updated_by=$3,updated_at=now(),version=ci.version+1 FROM species s WHERE ci.species_id=s.id AND ci.id=$4 AND ci.version=$5 AND (s.garden_id IS NULL OR s.garden_id=$6) RETURNING ci.updated_at,ci.version`, item.Text, item.Status, item.UpdatedBy, item.ID, item.Version, gardenID).Scan(&item.UpdatedAt, &item.Version)
if err == sql.ErrNoRows {
return storage.CareInstruction{}, storage.ErrEditConflict
}
return item, recordError(err)
}
// Delete removes a care instruction within its garden and species.
func (m CareInstructionModel) Delete(gardenID, speciesID, id int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM care_instructions ci USING species s WHERE ci.species_id=s.id AND ci.species_id=$1 AND ci.id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, speciesID, id, gardenID)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return storage.ErrRecordNotFound
}
return nil
}
+2
View File
@@ -0,0 +1,2 @@
// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
package postgres
+126
View File
@@ -0,0 +1,126 @@
package postgres
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"strings"
"time"
"gardomatic.kleiax.de/internal/storage"
)
// GardenInviteModel stores garden invitations in PostgreSQL.
// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL.
type GardenInviteModel struct{ DB *sql.DB }
// Upsert creates or replaces a pending invitation for a garden and email.
func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
invite.Token = rand.Text()
hash := sha256.Sum256([]byte(invite.Token))
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at)
SELECT $1,$2,$3,$4,$5,$6
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET
role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by,
expires_at=EXCLUDED.expires_at, created_at=now()
RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt)
return invite, err
}
func scanInvite(row scanner) (storage.GardenInvite, error) {
var invite storage.GardenInvite
err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt)
return invite, err
}
// GetByToken returns an unexpired pending invitation by its plaintext token.
func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) {
hash := sha256.Sum256([]byte(tokenPlaintext))
ctx, cancel := contextWithTimeout()
defer cancel()
invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:]))
if err != nil {
return storage.GardenInvite{}, recordError(err)
}
return invite, nil
}
// GetAllForGarden lists pending invitations for a garden.
func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []storage.GardenInvite{}
for rows.Next() {
invite, err := scanInvite(rows)
if err != nil {
return nil, err
}
result = append(result, invite)
}
return result, rows.Err()
}
// Delete revokes an invitation within its garden.
func (m GardenInviteModel) Delete(gardenID, inviteID int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return storage.ErrRecordNotFound
}
return nil
}
// Accept consumes an invitation and creates or updates membership in one
// transaction so a token cannot be accepted twice concurrently.
func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) {
hash := sha256.Sum256([]byte(tokenPlaintext))
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return storage.GardenMember{}, err
}
defer tx.Rollback()
invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:]))
if err != nil {
return storage.GardenMember{}, recordError(err)
}
if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) {
return storage.GardenMember{}, storage.ErrRecordNotFound
}
if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) {
return storage.GardenMember{}, storage.ErrConflict
}
member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role}
err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt)
if err != nil {
return storage.GardenMember{}, err
}
if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil {
return storage.GardenMember{}, err
}
if err = tx.Commit(); err != nil {
return storage.GardenMember{}, err
}
if err = GardenMemberModel(m).loadPermissions(&member); err != nil {
return storage.GardenMember{}, err
}
return member, nil
}
+222
View File
@@ -0,0 +1,222 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// GardenMemberModel stores garden memberships in PostgreSQL.
// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
type GardenMemberModel struct{ DB *sql.DB }
func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `
SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
UNION ALL
SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
ORDER BY granted DESC`, member.Role, member.GardenID)
if err != nil {
return err
}
defer rows.Close()
base := []string{}
overrides := []storage.GardenRolePermissionOverride{}
for rows.Next() {
var permission storage.GardenPermission
var granted bool
if err := rows.Scan(&permission, &granted); err != nil {
return err
}
if granted {
base = append(base, string(permission))
} else {
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
}
}
if err := rows.Err(); err != nil {
return err
}
member.Permissions = storage.ResolveGardenPermissions(base, overrides)
return nil
}
func scanGardenMember(s scanner) (storage.GardenMember, error) {
var member storage.GardenMember
err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
return member, err
}
// Insert adds a user to a garden.
func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
INSERT INTO garden_members (garden_id, user_id, role)
SELECT $1, $2, $3
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
).Scan(&member.JoinedAt)
if err != nil {
return storage.GardenMember{}, recordError(err)
}
if err = m.loadPermissions(&member); err != nil {
return storage.GardenMember{}, err
}
return member, nil
}
// Get returns a user's membership and effective permissions in a garden.
func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
SELECT garden_id, user_id, role, joined_at
FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
if err != nil {
return storage.GardenMember{}, recordError(err)
}
if err = m.loadPermissions(&member); err != nil {
return storage.GardenMember{}, err
}
return member, nil
}
// GetAllForGarden lists garden members with effective permissions.
func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `
SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
ORDER BY joined_at, user_id`, gardenID)
if err != nil {
return nil, err
}
defer rows.Close()
members := []storage.GardenMember{}
for rows.Next() {
var member storage.GardenMember
err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
if err != nil {
return nil, err
}
members = append(members, member)
if err = m.loadPermissions(&members[len(members)-1]); err != nil {
return nil, err
}
}
return members, rows.Err()
}
// Update changes a member's garden role.
func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return storage.GardenMember{}, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
return storage.GardenMember{}, err
}
var current storage.GardenRole
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(&current); err != nil {
return storage.GardenMember{}, recordError(err)
}
if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
var owners int
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
return storage.GardenMember{}, err
}
if owners < 2 {
return storage.GardenMember{}, storage.ErrConflict
}
}
result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
if err != nil {
return storage.GardenMember{}, err
}
if count, countErr := result.RowsAffected(); countErr != nil {
return storage.GardenMember{}, countErr
} else if count == 0 {
return storage.GardenMember{}, storage.ErrRecordNotFound
}
if err = tx.Commit(); err != nil {
return storage.GardenMember{}, err
}
if err = m.loadPermissions(&member); err != nil {
return storage.GardenMember{}, err
}
return member, nil
}
// Delete removes a non-owner membership from a garden.
func (m GardenMemberModel) Delete(gardenID, userID int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
return err
}
var role storage.GardenRole
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
return recordError(err)
}
if role == storage.GardenRoleOwner {
var owners int
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
return err
}
if owners < 2 {
return storage.ErrConflict
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
return err
}
return tx.Commit()
}
// TransferOwnership swaps owner and member roles atomically while preserving
// the invariant that a garden always has one owner.
func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
if fromUserID == toUserID {
return storage.ErrConflict
}
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
return err
}
var fromRole, toRole storage.GardenRole
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
return recordError(err)
}
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
return recordError(err)
}
if fromRole != storage.GardenRoleOwner {
return storage.ErrConflict
}
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
return err
}
return tx.Commit()
}
+116
View File
@@ -0,0 +1,116 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// GardenModel stores gardens in PostgreSQL.
// GardenModel implements storage.GardenModelInterface for PostgreSQL.
type GardenModel struct{ DB *sql.DB }
// Insert creates a garden and its owner membership atomically.
func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return storage.Garden{}, err
}
defer tx.Rollback()
query := `
INSERT INTO gardens (name, description, image_data, image_id)
VALUES ($1, $2, '', $3)
RETURNING id, created_at, updated_at, version`
err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan(
&garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version,
)
if err != nil {
return storage.Garden{}, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO garden_members (garden_id, user_id, role)
VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner)
if err != nil {
return storage.Garden{}, err
}
if err = tx.Commit(); err != nil {
return storage.Garden{}, err
}
return garden, nil
}
func scanGarden(s scanner) (storage.Garden, error) {
var garden storage.Garden
err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version)
return garden, err
}
// Get returns a garden by ID.
func (m GardenModel) Get(id int) (storage.Garden, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
garden, err := scanGarden(m.DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version
FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id))
if err != nil {
return storage.Garden{}, recordError(err)
}
return garden, nil
}
// GetAllForUser lists gardens visible to a user with resolved permissions.
func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role
FROM gardens g
INNER JOIN garden_members gm ON gm.garden_id = g.id
LEFT JOIN images i ON i.id=g.image_id
WHERE gm.user_id = $1
ORDER BY g.name, g.id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
gardens := []storage.Garden{}
for rows.Next() {
var garden storage.Garden
err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role)
if err != nil {
return nil, err
}
gardens = append(gardens, garden)
}
return gardens, rows.Err()
}
// Update changes a garden using optimistic locking.
func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
UPDATE gardens
SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE id = $4 AND version = $5
RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version,
).Scan(&garden.UpdatedAt, &garden.Version)
if err != nil {
if err == sql.ErrNoRows {
return storage.Garden{}, storage.ErrEditConflict
}
return storage.Garden{}, err
}
return garden, nil
}
// Delete removes a garden and its dependent records.
func (m GardenModel) Delete(id int) error {
return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id)
}
+87
View File
@@ -0,0 +1,87 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"gardomatic.kleiax.de/internal/storage"
"github.com/lib/pq"
)
const queryTimeout = 3 * time.Second
type scanner interface {
Scan(dest ...any) error
}
func contextWithTimeout() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), queryTimeout)
}
func jsonValue(value json.RawMessage) json.RawMessage {
if len(value) == 0 {
return json.RawMessage(`{}`)
}
return value
}
func deleteByID(db *sql.DB, query string, args ...any) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := db.ExecContext(ctx, query, args...)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return storage.ErrRecordNotFound
}
return nil
}
func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := db.ExecContext(ctx, query, gardenID, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return storage.ErrRecordNotFound
}
return nil
}
func recordError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return storage.ErrRecordNotFound
}
var pqError *pq.Error
if errors.As(err, &pqError) && pqError.Code == "23505" {
return storage.ErrConflict
}
if errors.As(err, &pqError) && pqError.Code == "23503" {
return storage.ErrConflict
}
return err
}
func nullableUserID(id int) any {
if id < 1 {
return nil
}
return id
}
+90
View File
@@ -0,0 +1,90 @@
package postgres
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"strings"
"gardomatic.kleiax.de/internal/storage"
)
// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image
// retrieval is always constrained by the owning garden.
type ImageModel struct{ DB *sql.DB }
// Insert stores an image in its garden's library.
func (m ImageModel) Insert(image storage.Image) (storage.Image, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
sum := sha256.Sum256(image.Data)
err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName,
image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt)
if err != nil {
return storage.Image{}, recordError(err)
}
image.Data = nil
return image, nil
}
// Get returns an image within its garden.
func (m ImageModel) Get(gardenID, id int) (storage.Image, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
var image storage.Image
err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan(
&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data)
if err != nil {
return storage.Image{}, recordError(err)
}
return image, nil
}
// GetAllForGarden lists image metadata matching filter.
func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at
FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query))
if err != nil {
return nil, err
}
defer rows.Close()
result := []storage.Image{}
for rows.Next() {
var image storage.Image
if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil {
return nil, err
}
result = append(result, image)
}
return result, rows.Err()
}
// CountForGarden returns the number of images in a garden library.
func (m ImageModel) CountForGarden(gardenID int) (int, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
var count int
err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count)
return count, err
}
// RecordAssignment appends an entity image-change history record.
func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error {
if sameImage(c.PreviousImageID, c.ImageID) {
return nil
}
ctx, cancel := contextWithTimeout()
defer cancel()
_, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy))
return err
}
func sameImage(a, b *int) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
+175
View File
@@ -0,0 +1,175 @@
package postgres
import (
"context"
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// JournalModel implements storage.JournalModelInterface for PostgreSQL and
// enforces the garden boundary on entries and attachments.
type JournalModel struct{ DB *sql.DB }
const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version`
func scanJournalEntry(s scanner) (storage.JournalEntry, error) {
var entry storage.JournalEntry
err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
return entry, err
}
// Insert creates a journal entry and its tags atomically.
func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
if entry.EntryType == "" {
entry.EntryType = storage.JournalEntryTypeJournal
}
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
if err != nil {
return storage.JournalEntry{}, recordError(err)
}
return entry, nil
}
// Get returns an entry with tags and attachment metadata within its garden.
func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id))
if err != nil {
return storage.JournalEntry{}, recordError(err)
}
entry.Attachments, err = m.attachments(ctx, entry.ID)
return entry, err
}
// GetAllForGarden lists entries of an optional type in a garden.
func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
if entryType == "" {
entryType = storage.JournalEntryTypeJournal
}
rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []storage.JournalEntry{}
for rows.Next() {
entry, scanErr := scanJournalEntry(rows)
if scanErr != nil {
return nil, scanErr
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return nil, err
}
for i := range entries {
entries[i].Attachments, err = m.attachments(ctx, entries[i].ID)
if err != nil {
return nil, err
}
}
return entries, nil
}
// Update changes an entry and its tags atomically using optimistic locking.
func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
if err == sql.ErrNoRows {
return storage.JournalEntry{}, storage.ErrEditConflict
}
if err != nil {
return storage.JournalEntry{}, err
}
return entry, nil
}
// Delete removes an entry within its garden.
func (m JournalModel) Delete(gardenID, id int) error {
return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id)
}
// InsertAttachment adds inline media or a library-image link to an entry.
func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
var entryType storage.JournalEntryType
if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil {
return storage.JournalAttachment{}, recordError(err)
}
if attachment.ImageID != nil {
image, err := ImageModel(m).Get(gardenID, *attachment.ImageID)
if err != nil {
return storage.JournalAttachment{}, err
}
attachment.MediaType, attachment.Size = image.MediaType, image.Size
if attachment.FileName == "" {
attachment.FileName = image.FileName
}
if attachment.FileName == "" {
attachment.FileName = "Bild"
}
} else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" {
image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)})
if err != nil {
return storage.JournalAttachment{}, err
}
attachment.ImageID = &image.ID
}
data := attachment.Data
if attachment.ImageID != nil {
data = []byte{}
}
size := int64(len(attachment.Data))
if attachment.ImageID != nil {
size = attachment.Size
}
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt)
if err != nil {
return storage.JournalAttachment{}, recordError(err)
}
attachment.EntryID, attachment.Size = entryID, size
attachment.Data = nil
return attachment, nil
}
// GetAttachment returns attachment metadata and data within its garden and entry.
func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
var a storage.JournalAttachment
err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID)
if err != nil {
return storage.JournalAttachment{}, recordError(err)
}
return a, nil
}
// DeleteAttachment removes an attachment within its garden and entry.
func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error {
return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID)
}
func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) {
rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []storage.JournalAttachment{}
for rows.Next() {
var a storage.JournalAttachment
if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil {
return nil, err
}
result = append(result, a)
}
return result, rows.Err()
}
@@ -0,0 +1,105 @@
package postgres
import (
"database/sql"
"errors"
"os"
"testing"
"gardomatic.kleiax.de/internal/storage"
)
func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) {
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
if dsn == "" {
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err = db.Ping(); err != nil {
t.Fatalf("connect to PostgreSQL: %v", err)
}
var userID, gardenID, foreignGardenID int
if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID)
_, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
})
model := JournalModel{DB: db}
entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"})
if err != nil {
t.Fatalf("insert entry: %v", err)
}
foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"})
if err != nil {
t.Fatalf("insert foreign entry: %v", err)
}
if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
t.Fatalf("foreign lookup: %v", err)
}
pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"})
if err != nil {
t.Fatalf("insert pinboard entry: %v", err)
}
tags := TagModel{DB: db}
storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"})
if err != nil || len(storedTags) != 2 {
t.Fatalf("set tags: %#v, %v", storedTags, err)
}
attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")})
if err != nil {
t.Fatalf("insert attachment: %v", err)
}
pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")})
if err != nil {
t.Fatalf("insert pinboard attachment: %v", err)
}
if pinboardAttachment.ImageID == nil {
t.Fatal("pinboard image was not added to the shared image library")
}
pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID)
if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) {
t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err)
}
loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID)
if err != nil || string(loaded.Data) != "jpeg" {
t.Fatalf("load attachment: %#v, %v", loaded, err)
}
if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) {
t.Fatalf("foreign attachment lookup: %v", err)
}
entry.Title = "Große Ernte"
updated, err := model.Update(gardenID, entry)
if err != nil || updated.Version != 2 {
t.Fatalf("update entry: %#v, %v", updated, err)
}
listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal)
if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" {
t.Fatalf("list entries: %#v, %v", listed, err)
}
pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard)
if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 {
t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err)
}
if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil {
t.Fatalf("delete attachment: %v", err)
}
if err = model.Delete(gardenID, entry.ID); err != nil {
t.Fatalf("delete entry: %v", err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// LocationModel stores garden locations in PostgreSQL.
// LocationModel implements storage.LocationModelInterface for PostgreSQL and
// scopes hierarchical locations to their garden.
type LocationModel struct{ DB *sql.DB }
const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm,
l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version,
COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)`
func scanLocation(s scanner) (storage.Location, error) {
var location storage.Location
err := s.Scan(
&location.ID, &location.GardenID, &location.ParentID, &location.Name,
&location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction,
&location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy,
)
return location, err
}
// Insert creates a location in its garden.
func (m LocationModel) Insert(location storage.Location) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
INSERT INTO locations
(garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13)
RETURNING id, created_at, updated_at, version`,
location.GardenID, location.ParentID, location.Name, location.Description,
location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy),
).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version)
if err != nil {
return storage.Location{}, err
}
return location, nil
}
// Get returns a location within its garden.
func (m LocationModel) Get(gardenID, id int) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id))
if err != nil {
return storage.Location{}, recordError(err)
}
return location, nil
}
// GetAllForGarden lists locations in a garden.
func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+`
FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID)
if err != nil {
return nil, err
}
defer rows.Close()
locations := []storage.Location{}
for rows.Next() {
location, err := scanLocation(rows)
if err != nil {
return nil, err
}
locations = append(locations, location)
}
return locations, rows.Err()
}
// Update changes a location using optimistic locking.
func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) {
ctx, cancel := contextWithTimeout()
defer cancel()
err := m.DB.QueryRowContext(ctx, `
UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4,
area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11,
updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id = $12 AND id = $13 AND version = $14
RETURNING updated_at, version`,
location.ParentID, location.Name, location.Description, location.Kind,
location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy),
gardenID, location.ID, location.Version,
).Scan(&location.UpdatedAt, &location.Version)
if err != nil {
if err == sql.ErrNoRows {
return storage.Location{}, storage.ErrEditConflict
}
return storage.Location{}, err
}
return location, nil
}
// Delete removes a location within its garden.
func (m LocationModel) Delete(gardenID, id int) error {
ctx, cancel := contextWithTimeout()
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return storage.ErrRecordNotFound
}
return nil
}
@@ -0,0 +1,75 @@
package postgres
import (
"database/sql"
"encoding/json"
"errors"
"os"
"testing"
"gardomatic.kleiax.de/internal/storage"
)
func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) {
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
if dsn == "" {
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Ping(); err != nil {
t.Fatalf("connect to PostgreSQL: %v", err)
}
var gardenID, foreignGardenID int
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) })
locations := LocationModel{DB: db}
local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)})
if err != nil {
t.Fatalf("insert local location: %v", err)
}
foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)})
if err != nil {
t.Fatalf("insert foreign location: %v", err)
}
if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err)
}
var plantID int
if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil {
t.Fatal(err)
}
plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID)
if err != nil {
t.Fatalf("list plants with planter join: %v", err)
}
if len(plants) != 1 || plants[0].ID != plantID {
t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID)
}
assignments := PlantLocationModel{DB: db}
created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2})
if err != nil {
t.Fatalf("insert local assignment: %v", err)
}
if created.ID == 0 {
t.Fatal("local assignment has no id")
}
if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) {
t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err)
}
listed, err := assignments.GetAllForPlant(gardenID, plantID)
if err != nil || len(listed) != 1 {
t.Fatalf("list assignments: values=%+v err=%v", listed, err)
}
}
@@ -0,0 +1,49 @@
DROP TABLE plant_status_history;
DROP TABLE application_settings;
DROP TABLE journal_attachments;
DROP TABLE journal_entry_tags;
DROP TABLE journal_entries;
DROP TABLE entity_image_history;
ALTER TABLE locations DROP COLUMN image_id;
ALTER TABLE plants DROP COLUMN image_id;
ALTER TABLE species DROP COLUMN image_id;
ALTER TABLE gardens DROP COLUMN image_id;
DROP TABLE images;
DROP TABLE species_tags;
DROP TABLE plant_tags;
DROP TABLE task_tags;
DROP TABLE tags;
DROP TABLE task_priorities;
DROP TABLE task_template_opt_outs;
DROP TABLE tasks;
DROP TABLE plant_locations;
DROP TABLE plants;
DROP TABLE locations;
DROP TABLE species_task_templates;
DROP TABLE care_instructions;
DROP TABLE species;
DROP TABLE species_categories;
DROP TABLE garden_role_permission_overrides;
DROP TABLE garden_invites;
DROP TABLE garden_members;
ALTER TABLE users DROP CONSTRAINT users_application_role_fkey;
DROP TABLE role_permissions;
DROP TABLE roles;
DROP TABLE gardens;
DROP TABLE user_email_changes;
DROP TABLE tokens;
DROP TABLE sessions;
DROP TABLE users;
DROP TYPE task_template_origin;
DROP TYPE task_trigger_type;
DROP TYPE care_instruction_status;
DROP TYPE plant_status;
DROP TYPE plant_lifecycle;
DROP TYPE soil_reaction;
DROP TYPE soil_condition;
DROP TYPE sun_exposure;
DROP EXTENSION citext;
@@ -0,0 +1,524 @@
-- Extensions and domain types
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade');
CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy');
CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral');
CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial');
CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested');
CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned');
CREATE TYPE task_trigger_type AS ENUM (
'month_of_year',
'relative_to_planting',
'relative_to_last_task',
'relative_to_sowing',
'relative_to_harvest',
'relative_to_species_planting'
);
CREATE TYPE task_template_origin AS ENUM (
'manual',
'season_sowing',
'season_planting',
'season_harvest'
);
-- Authentication and users
CREATE TABLE users (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name text NOT NULL,
email citext NOT NULL UNIQUE,
password_hash bytea NOT NULL,
activated boolean NOT NULL,
application_role text NOT NULL DEFAULT 'application:user',
color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int],
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1,
CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
);
CREATE TABLE sessions (
token text PRIMARY KEY,
data bytea NOT NULL,
expiry timestamptz NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions (expiry);
CREATE TABLE tokens (
hash bytea PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expiry timestamptz NOT NULL,
scope text NOT NULL
);
CREATE TABLE user_email_changes (
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
email citext NOT NULL UNIQUE,
token_hash bytea NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Gardens, roles, and permissions
CREATE TABLE gardens (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name text NOT NULL,
description text NOT NULL DEFAULT '',
image_data text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE TABLE roles (
name text PRIMARY KEY,
scope text NOT NULL CHECK (scope IN ('application', 'garden')),
label text NOT NULL,
system boolean NOT NULL DEFAULT false,
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT roles_scope_garden_check CHECK (
(scope = 'application' AND garden_id IS NULL) OR scope = 'garden'
)
);
CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL;
CREATE TABLE role_permissions (
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
permission text NOT NULL,
PRIMARY KEY (role_name, permission)
);
ALTER TABLE users
ADD CONSTRAINT users_application_role_fkey
FOREIGN KEY (application_role) REFERENCES roles(name);
CREATE TABLE garden_members (
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (garden_id, user_id)
);
CREATE INDEX garden_members_user_id_idx ON garden_members (user_id);
CREATE TABLE garden_invites (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
email citext NOT NULL,
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
token_hash bytea NOT NULL UNIQUE,
invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX garden_invites_pending_email_unique
ON garden_invites (garden_id, email) WHERE accepted_at IS NULL;
CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at);
CREATE TABLE garden_role_permission_overrides (
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
permission text NOT NULL,
granted boolean NOT NULL,
PRIMARY KEY (garden_id, role_name, permission)
);
-- Species catalogue and care instructions
CREATE TABLE species_categories (
id bigserial PRIMARY KEY,
name text NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
lifecycle plant_lifecycle,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
version integer NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name));
CREATE TABLE species (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT,
common_name text NOT NULL,
cultivar text NOT NULL DEFAULT '',
botanical_name text NOT NULL DEFAULT '',
sun_exposure sun_exposure,
soil_condition soil_condition,
soil_reaction soil_reaction,
winter_protection text,
spacing_cm integer,
height_cm integer,
sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12),
sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31),
sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12),
sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31),
planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12),
planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31),
planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12),
planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31),
harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12),
harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31),
harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12),
harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31),
notes text NOT NULL DEFAULT '',
attributes jsonb NOT NULL DEFAULT '{}',
image_data text NOT NULL DEFAULT '',
created_by bigint REFERENCES users(id),
updated_by bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX species_global_unique
ON species (common_name, cultivar) WHERE garden_id IS NULL;
CREATE UNIQUE INDEX species_garden_unique
ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL;
CREATE TABLE care_instructions (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
text text NOT NULL,
status care_instruction_status NOT NULL DEFAULT 'untested',
created_by bigint NOT NULL REFERENCES users(id),
updated_by bigint NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE INDEX care_instructions_species ON care_instructions(species_id);
CREATE TABLE species_task_templates (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
trigger_type task_trigger_type NOT NULL,
month_from smallint CHECK (month_from BETWEEN 1 AND 12),
day_from smallint CHECK (day_from BETWEEN 1 AND 31),
month_to smallint CHECK (month_to BETWEEN 1 AND 12),
day_to smallint CHECK (day_to BETWEEN 1 AND 31),
offset_days_from integer,
offset_days_to integer,
interval_days smallint,
trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0),
trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')),
duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0),
duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')),
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
origin task_template_origin NOT NULL DEFAULT 'manual',
priority smallint NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1,
CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to)
);
CREATE INDEX species_task_templates_species_id_idx
ON species_task_templates (species_id) WHERE active = true;
CREATE UNIQUE INDEX species_task_templates_derived_origin_key
ON species_task_templates (species_id, origin) WHERE origin <> 'manual';
-- Locations, plants, and tasks
CREATE TABLE locations (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
parent_id bigint REFERENCES locations(id) ON DELETE SET NULL,
name text NOT NULL,
description text NOT NULL DEFAULT '',
kind text NOT NULL DEFAULT '',
area_sqm numeric(10,2),
sun_exposure sun_exposure,
soil_condition soil_condition,
soil_reaction soil_reaction,
attributes jsonb NOT NULL DEFAULT '{}',
image_data text NOT NULL DEFAULT '',
created_by bigint REFERENCES users(id),
updated_by bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE INDEX locations_garden_id_idx ON locations (garden_id);
CREATE INDEX locations_parent_id_idx ON locations (parent_id);
CREATE TABLE plants (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
species_id bigint REFERENCES species(id) ON DELETE SET NULL,
name text NOT NULL,
notes text NOT NULL DEFAULT '',
acquired_at date,
status plant_status NOT NULL DEFAULT 'alive',
removed_at date,
attributes jsonb NOT NULL DEFAULT '{}',
image_data text NOT NULL DEFAULT '',
created_by bigint REFERENCES users(id),
updated_by bigint REFERENCES users(id),
planted_by bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive';
CREATE TABLE plant_locations (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
quantity integer NOT NULL DEFAULT 1,
planted_at date,
removed_at date,
notes text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX plant_location_active
ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL;
CREATE INDEX plant_locations_location_id_idx
ON plant_locations (location_id) WHERE removed_at IS NULL;
CREATE TABLE tasks (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
plant_id bigint REFERENCES plants(id) ON DELETE CASCADE,
location_id bigint REFERENCES locations(id) ON DELETE CASCADE,
template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
due_at_start timestamptz,
due_at_end timestamptz,
generated_for date,
completed_at timestamptz,
completed_by bigint REFERENCES users(id),
priority smallint NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL,
plant_status_on_completion plant_status,
created_by bigint NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1,
CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end),
CONSTRAINT tasks_completion_user_check CHECK (
(completed_at IS NULL AND completed_by IS NULL) OR
(completed_at IS NOT NULL AND completed_by IS NOT NULL)
)
);
CREATE INDEX tasks_garden_id_due_at_end_idx
ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL;
CREATE INDEX tasks_garden_id_due_at_start_idx
ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL;
CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL;
CREATE UNIQUE INDEX tasks_repeat_from_unique
ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL;
CREATE UNIQUE INDEX tasks_template_slot_unique
ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL;
CREATE TABLE task_template_opt_outs (
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (plant_id, template_id)
);
CREATE TABLE task_priorities (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name text NOT NULL UNIQUE,
value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100),
sort_order integer NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
-- Tags
CREATE TABLE tags (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
name text NOT NULL,
UNIQUE (garden_id, name)
);
CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL;
CREATE TABLE task_tags (
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
CREATE TABLE plant_tags (
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (plant_id, tag_id)
);
CREATE TABLE species_tags (
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (species_id, tag_id)
);
-- Image library
CREATE TABLE images (
id bigserial PRIMARY KEY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
file_name text NOT NULL DEFAULT '',
media_type text NOT NULL,
data bytea NOT NULL,
size bigint NOT NULL,
checksum text NOT NULL,
source text NOT NULL DEFAULT 'upload',
created_by bigint REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC);
CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum);
ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
CREATE TABLE entity_image_history (
id bigserial PRIMARY KEY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')),
entity_id bigint NOT NULL,
previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL,
image_id bigint REFERENCES images(id) ON DELETE SET NULL,
changed_by bigint REFERENCES users(id) ON DELETE SET NULL,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX entity_image_history_entity_idx
ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC);
-- Journal
CREATE TABLE journal_entries (
id bigserial PRIMARY KEY,
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
author_id bigint NOT NULL REFERENCES users(id),
entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')),
title text NOT NULL,
body text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE INDEX journal_entries_garden_created_idx
ON journal_entries(garden_id, created_at DESC, id DESC);
CREATE INDEX journal_entries_garden_type_created_idx
ON journal_entries(garden_id, entry_type, created_at DESC, id DESC);
CREATE TABLE journal_entry_tags (
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (journal_entry_id, tag_id)
);
CREATE TABLE journal_attachments (
id bigserial PRIMARY KEY,
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
image_id bigint REFERENCES images(id) ON DELETE RESTRICT,
file_name text NOT NULL,
media_type text NOT NULL,
data bytea NOT NULL,
size bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id);
-- Application settings and lifecycle history
CREATE TABLE application_settings (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
lifecycle_status_enabled boolean NOT NULL DEFAULT false,
lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12),
lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31),
timezone text NOT NULL DEFAULT 'Europe/Berlin',
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 1
);
CREATE TABLE plant_status_history (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
from_status plant_status NOT NULL,
to_status plant_status NOT NULL,
reason text NOT NULL,
effective_at date NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX plant_status_history_plant_id_idx
ON plant_status_history (plant_id, created_at DESC);
-- Initial reference data
INSERT INTO roles (name, scope, label, system) VALUES
('application:user', 'application', 'Nutzer', true),
('application:admin', 'application', 'Administrator', true),
('owner', 'garden', 'Eigentümer', true),
('admin', 'garden', 'Administrator', true),
('member', 'garden', 'Mitglied', true),
('worker', 'garden', 'Mitarbeiter', true),
('viewer', 'garden', 'Leser', true);
INSERT INTO role_permissions (role_name, permission) VALUES
('application:user', 'gardens:create'),
('application:admin', 'gardens:create'),
('application:admin', 'global_species:write'),
('application:admin', 'users:manage'),
('application:admin', 'application_settings:write'),
('application:admin', 'roles:manage'),
('owner', '*'),
('admin', 'garden:*'),
('member', 'garden:read'),
('member', 'content:write'),
('member', 'plants:create'),
('member', 'plants:read:own'),
('member', 'plants:read:other'),
('member', 'plants:update:own'),
('member', 'plants:delete:own'),
('member', 'locations:create'),
('member', 'locations:read:own'),
('member', 'locations:read:other'),
('member', 'locations:update:own'),
('member', 'locations:delete:own'),
('member', 'tasks:create'),
('member', 'tasks:read:own'),
('member', 'tasks:read:other'),
('member', 'tasks:update:own'),
('member', 'tasks:delete:own'),
('member', 'tasks:complete:own'),
('member', 'tasks:complete:other'),
('worker', 'garden:read'),
('worker', 'tasks:read:own'),
('worker', 'tasks:read:other'),
('worker', 'tasks:complete:own'),
('worker', 'tasks:complete:other'),
('viewer', 'garden:read'),
('viewer', 'plants:read:own'),
('viewer', 'plants:read:other'),
('viewer', 'locations:read:own'),
('viewer', 'locations:read:other'),
('viewer', 'tasks:read:own'),
('viewer', 'tasks:read:other');
INSERT INTO task_priorities (name, value, sort_order) VALUES
('Niedrig', -5, 10),
('Normal', 0, 20),
('Erhöht', 3, 30),
('Hoch', 5, 40);
INSERT INTO application_settings (singleton) VALUES (true);
INSERT INTO species_categories (name, sort_order) VALUES
('Gehölz', 10),
('Gemüse', 20),
('Kraut', 30),
('Obst', 40),
('Staude', 50);
+33
View File
@@ -0,0 +1,33 @@
package postgres
import (
"database/sql"
"gardomatic.kleiax.de/internal/storage"
)
// New binds all PostgreSQL model implementations to db.
func New(db *sql.DB) storage.Models {
return storage.Models{
ApplicationSettings: ApplicationSettingsModel{DB: db},
Roles: RoleModel{DB: db},
Gardens: GardenModel{DB: db},
GardenMembers: GardenMemberModel{DB: db},
GardenInvites: GardenInviteModel{DB: db},
Locations: LocationModel{DB: db},
Plants: PlantModel{DB: db},
PlantLocations: PlantLocationModel{DB: db},
Species: SpeciesModel{DB: db},
CareInstructions: CareInstructionModel{DB: db},
SpeciesCategories: SpeciesCategoryModel{DB: db},
TaskPriorities: TaskPriorityModel{DB: db},
SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db},
Tasks: TaskModel{DB: db},
Journal: JournalModel{DB: db},
Images: ImageModel{DB: db},
Tags: TagModel{DB: db},
TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db},
Tokens: TokenModel{DB: db},
Users: UserModel{DB: db},
}
}
@@ -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)
}
}
+47
View File
@@ -0,0 +1,47 @@
package storage
import "time"
// RoleScope separates application-wide privileges from garden membership roles.
type RoleScope string
// Supported role scopes.
const (
RoleScopeApplication RoleScope = "application"
RoleScopeGarden RoleScope = "garden"
)
// Role is a reusable, globally defined bundle of permissions.
type Role struct {
Name string `json:"name"`
Scope RoleScope `json:"scope"`
GardenID *int `json:"garden_id,omitempty"`
Label string `json:"label"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GardenRolePermissionOverride grants or revokes one role permission in a
// single garden without modifying the shared role template.
type GardenRolePermissionOverride struct {
GardenID int `json:"garden_id"`
RoleName string `json:"role_name"`
Permission string `json:"permission"`
Granted bool `json:"granted"`
}
// RoleModelInterface manages application roles, garden role templates, and
// garden-specific permission overrides.
type RoleModelInterface interface {
List(scope RoleScope) ([]Role, error)
ListForGarden(gardenID int) ([]Role, error)
Get(name string) (Role, error)
GetForGarden(gardenID int, name string) (Role, error)
Create(role Role) (Role, error)
Update(role Role) (Role, error)
Delete(name string) error
ListGardenOverrides(gardenID int) ([]GardenRolePermissionOverride, error)
ReplaceGardenOverrides(gardenID int, roleName string, overrides []GardenRolePermissionOverride) error
}
+106
View File
@@ -0,0 +1,106 @@
package storage
import (
"encoding/json"
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// SpeciesModelInterface persists global and garden-specific species data.
type SpeciesModelInterface interface {
Insert(species Species) (Species, error)
Get(gardenID, id int) (Species, error)
GetAllForGarden(gardenID int) ([]Species, error)
Update(gardenID int, species Species) (Species, error)
Delete(gardenID, id int) error
}
// Species contains reusable botanical and cultivation master data.
type Species struct {
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
HeightCM *int `json:"height_cm,omitempty"`
SowMonthFrom *int `json:"sow_month_from,omitempty"`
SowDayFrom *int `json:"sow_day_from,omitempty"`
SowMonthTo *int `json:"sow_month_to,omitempty"`
SowDayTo *int `json:"sow_day_to,omitempty"`
PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
PlantingDayFrom *int `json:"planting_day_from,omitempty"`
PlantingMonthTo *int `json:"planting_month_to,omitempty"`
PlantingDayTo *int `json:"planting_day_to,omitempty"`
HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
HarvestDayTo *int `json:"harvest_day_to,omitempty"`
Notes string `json:"notes"`
ImageData string `json:"image_data,omitempty"`
ImageID *int `json:"image_id,omitempty"`
Attributes json.RawMessage `json:"attributes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Tags []string `json:"tags,omitempty"`
CreatedBy int `json:"created_by"`
UpdatedBy int `json:"updated_by"`
}
// ValidateSpecies applies persistence-independent species validation rules.
func ValidateSpecies(v *validate.Validator, species Species) {
v.Check(strings.TrimSpace(species.CommonName) != "", "common_name", "must be provided")
v.Check(len(species.CommonName) <= 500, "common_name", "must not be more than 500 bytes long")
v.Check(len(species.Cultivar) <= 500, "cultivar", "must not be more than 500 bytes long")
v.Check(len(species.BotanicalName) <= 500, "botanical_name", "must not be more than 500 bytes long")
if species.CategoryID != nil {
v.Check(*species.CategoryID > 0, "category_id", "must be a positive integer")
}
v.Check(len(species.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long")
v.Check(len(species.Attributes) == 0 || json.Valid(species.Attributes), "attributes", "must be valid JSON")
ValidateTags(v, species.Tags)
validateOptionalEnum(v, "sun_exposure", species.SunExposure, "sunny", "partial_shade", "shade")
validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy")
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
validateOptionalPositive(v, "height_cm", species.HeightCM)
validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12)
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
validateOptionalCalendarPart(v, "sow_day_to", species.SowDayTo, 31)
validateOptionalCalendarPart(v, "planting_month_from", species.PlantingMonthFrom, 12)
validateOptionalCalendarPart(v, "planting_day_from", species.PlantingDayFrom, 31)
validateOptionalCalendarPart(v, "planting_month_to", species.PlantingMonthTo, 12)
validateOptionalCalendarPart(v, "planting_day_to", species.PlantingDayTo, 31)
validateOptionalCalendarPart(v, "harvest_month_from", species.HarvestMonthFrom, 12)
validateOptionalCalendarPart(v, "harvest_day_from", species.HarvestDayFrom, 31)
validateOptionalCalendarPart(v, "harvest_month_to", species.HarvestMonthTo, 12)
validateOptionalCalendarPart(v, "harvest_day_to", species.HarvestDayTo, 31)
}
func validateOptionalEnum(v *validate.Validator, field string, value *string, allowed ...string) {
if value != nil {
v.Check(validate.PermittedValue(*value, allowed...), field, "is invalid")
}
}
func validateOptionalPositive(v *validate.Validator, field string, value *int) {
if value != nil {
v.Check(*value > 0, field, "must be greater than zero")
}
}
func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
if value != nil {
v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
}
}
+35
View File
@@ -0,0 +1,35 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// SpeciesCategoryModelInterface persists the configurable species taxonomy.
type SpeciesCategoryModelInterface interface {
Insert(category SpeciesCategory) (SpeciesCategory, error)
Get(id int) (SpeciesCategory, error)
GetAll() ([]SpeciesCategory, error)
Update(category SpeciesCategory) (SpeciesCategory, error)
}
// SpeciesCategory is a globally managed option for classifying species.
type SpeciesCategory struct {
ID int `json:"id"`
Name string `json:"name"`
SortOrder int `json:"sort_order"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Lifecycle *string `json:"lifecycle,omitempty"`
}
// ValidateSpeciesCategory applies validation rules independent of persistence.
func ValidateSpeciesCategory(v *validate.Validator, category SpeciesCategory) {
v.Check(strings.TrimSpace(category.Name) != "", "name", "must be provided")
v.Check(len(category.Name) <= 200, "name", "must not be more than 200 bytes long")
validateOptionalEnum(v, "lifecycle", category.Lifecycle, "annual", "biennial", "perennial")
}
+117
View File
@@ -0,0 +1,117 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// TaskTriggerType identifies how a species task template calculates its due window.
type TaskTriggerType string
// TaskDurationUnit is the calendar unit used for offsets and durations.
type TaskDurationUnit string
// TaskTemplateOrigin identifies whether a template was entered manually or derived from species data.
type TaskTemplateOrigin string
// Supported task duration units.
const (
TaskDurationDay TaskDurationUnit = "day"
TaskDurationWeek TaskDurationUnit = "week"
TaskDurationMonth TaskDurationUnit = "month"
)
// Supported task trigger types.
const (
// TaskTriggerMonthOfYear repeats within a calendar-year date window.
TaskTriggerMonthOfYear TaskTriggerType = "month_of_year"
// TaskTriggerRelativeToPlanting is offset from the planting date.
TaskTriggerRelativeToPlanting TaskTriggerType = "relative_to_planting"
// TaskTriggerRelativeToLastTask is offset from the last completion.
TaskTriggerRelativeToLastTask TaskTriggerType = "relative_to_last_task"
TaskTriggerRelativeToSowing TaskTriggerType = "relative_to_sowing"
TaskTriggerRelativeToHarvest TaskTriggerType = "relative_to_harvest"
TaskTriggerRelativeToSpeciesPlanting TaskTriggerType = "relative_to_species_planting"
)
// Supported template origins.
const (
TaskTemplateOriginManual TaskTemplateOrigin = "manual"
TaskTemplateOriginSeasonSowing TaskTemplateOrigin = "season_sowing"
TaskTemplateOriginSeasonPlanting TaskTemplateOrigin = "season_planting"
TaskTemplateOriginSeasonHarvest TaskTemplateOrigin = "season_harvest"
)
// SpeciesTaskTemplateModelInterface persists recurring rules for species tasks.
type SpeciesTaskTemplateModelInterface interface {
Insert(template SpeciesTaskTemplate) (SpeciesTaskTemplate, error)
Get(gardenID, id int) (SpeciesTaskTemplate, error)
GetAllForSpecies(gardenID, speciesID int) ([]SpeciesTaskTemplate, error)
Update(gardenID int, template SpeciesTaskTemplate) (SpeciesTaskTemplate, error)
Delete(gardenID, id int) error
}
// ValidateSpeciesTaskTemplate applies scheduling and recurrence rules to a
// species task template.
func ValidateSpeciesTaskTemplate(v *validate.Validator, template SpeciesTaskTemplate) {
v.Check(strings.TrimSpace(template.Title) != "", "title", "must be provided")
v.Check(len(template.Title) <= 500, "title", "must not be more than 500 bytes long")
v.Check(len(template.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
v.Check(template.Priority >= -100 && template.Priority <= 100, "priority", "must be between -100 and 100")
v.Check(template.TriggerType == TaskTriggerMonthOfYear || template.TriggerType == TaskTriggerRelativeToPlanting || template.TriggerType == TaskTriggerRelativeToLastTask || template.TriggerType == TaskTriggerRelativeToSowing || template.TriggerType == TaskTriggerRelativeToHarvest || template.TriggerType == TaskTriggerRelativeToSpeciesPlanting, "trigger_type", "is invalid")
v.Check(template.Origin == TaskTemplateOriginManual || template.Origin == TaskTemplateOriginSeasonSowing || template.Origin == TaskTemplateOriginSeasonPlanting || template.Origin == TaskTemplateOriginSeasonHarvest, "origin", "is invalid")
if template.TriggerType == TaskTriggerMonthOfYear {
v.Check(template.MonthFrom != nil && *template.MonthFrom >= 1 && *template.MonthFrom <= 12, "month_from", "must be between 1 and 12")
validateTemplateDay(v, "day_from", template.DayFrom)
v.Check(template.DayFrom != nil, "day_from", "must be provided")
} else {
v.Check(template.TriggerOffset >= 0, "trigger_offset", "must not be negative")
}
v.Check(template.Duration >= 0, "duration", "must not be negative")
v.Check(validTaskDurationUnit(template.DurationUnit), "duration_unit", "is invalid")
v.Check(validTaskDurationUnit(template.TriggerOffsetUnit), "trigger_offset_unit", "is invalid")
v.Check(template.Recurrence == TaskRecurrenceNone || template.Recurrence == TaskRecurrenceDaily || template.Recurrence == TaskRecurrenceWeekly || template.Recurrence == TaskRecurrenceMonthly || template.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid")
if template.Recurrence != TaskRecurrenceNone {
v.Check(template.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero")
}
}
func validTaskDurationUnit(unit TaskDurationUnit) bool {
return unit == TaskDurationDay || unit == TaskDurationWeek || unit == TaskDurationMonth
}
func validateTemplateDay(v *validate.Validator, field string, value *int) {
if value != nil {
v.Check(*value >= 1 && *value <= 31, field, "must be between 1 and 31")
}
}
// SpeciesTaskTemplate defines a recurring task rule for a species.
type SpeciesTaskTemplate struct {
ID int `json:"id"`
SpeciesID int `json:"species_id"`
Origin TaskTemplateOrigin `json:"origin"`
Title string `json:"title"`
Description string `json:"description"`
TriggerType TaskTriggerType `json:"trigger_type"`
MonthFrom *int `json:"month_from,omitempty"`
DayFrom *int `json:"day_from,omitempty"`
MonthTo *int `json:"month_to,omitempty"`
DayTo *int `json:"day_to,omitempty"`
OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
OffsetDaysTo *int `json:"offset_days_to,omitempty"`
IntervalDays *int `json:"interval_days,omitempty"`
TriggerOffset int `json:"trigger_offset"`
TriggerOffsetUnit TaskDurationUnit `json:"trigger_offset_unit"`
Duration int `json:"duration"`
DurationUnit TaskDurationUnit `json:"duration_unit"`
Recurrence TaskRecurrence `json:"recurrence,omitempty"`
RecurrenceInterval int `json:"recurrence_interval"`
Priority int `json:"priority"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
}
+50
View File
@@ -0,0 +1,50 @@
package storage
import (
"sort"
"strings"
"gardomatic.kleiax.de/internal/platform/validate"
)
// TagEntity identifies a resource type that can be tagged.
type TagEntity string
// Supported taggable entity types.
const (
TagEntityTask TagEntity = "task"
TagEntityPlant TagEntity = "plant"
TagEntitySpecies TagEntity = "species"
TagEntityJournal TagEntity = "journal_entry"
)
// TagModelInterface lists tags and atomically replaces an entity's tag set.
type TagModelInterface interface {
Get(gardenID int, entity TagEntity, entityID int) ([]string, error)
Set(gardenID int, entity TagEntity, entityID int, tags []string) ([]string, error)
GetAllForGarden(gardenID int) ([]string, error)
}
// NormalizeTags trims, removes empty values, and deduplicates tags while
// preserving their first occurrence.
func NormalizeTags(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
sort.Strings(result)
return result
}
// ValidateTags applies count and length limits to a normalized tag list.
func ValidateTags(v *validate.Validator, tags []string) {
v.Check(len(tags) <= 20, "tags", "must contain at most 20 tags")
for _, tag := range tags {
v.Check(len(tag) <= 50, "tags", "each tag must contain at most 50 bytes")
}
}
+35
View File
@@ -0,0 +1,35 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// TaskPriorityModelInterface manages the application-wide priority catalogue.
type TaskPriorityModelInterface interface {
Insert(priority TaskPriority) (TaskPriority, error)
Get(id int) (TaskPriority, error)
GetAll() ([]TaskPriority, error)
Update(priority TaskPriority) (TaskPriority, error)
}
// TaskPriority maps a user-facing label to the numeric value stored on tasks.
type TaskPriority struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
SortOrder int `json:"sort_order"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
}
// ValidateTaskPriority applies catalogue-entry validation rules.
func ValidateTaskPriority(v *validate.Validator, priority TaskPriority) {
v.Check(strings.TrimSpace(priority.Name) != "", "name", "must be provided")
v.Check(len(priority.Name) <= 100, "name", "must not be more than 100 bytes long")
v.Check(priority.Value >= -100 && priority.Value <= 100, "value", "must be between -100 and 100")
}
@@ -0,0 +1,9 @@
package storage
// TaskTemplateOptOutModelInterface records per-plant suppression of automatic
// task generation from a species template.
type TaskTemplateOptOutModelInterface interface {
IsOptedOut(gardenID, plantID, templateID int) (bool, error)
GetAllForPlant(gardenID, plantID int) ([]int, error)
Set(gardenID, plantID, templateID int, optedOut bool) error
}
+88
View File
@@ -0,0 +1,88 @@
package storage
import (
"strings"
"time"
"gardomatic.kleiax.de/internal/platform/validate"
)
// TaskRecurrence identifies the calendar interval of a manual task.
type TaskRecurrence string
// Supported task recurrence values. An empty value disables recurrence.
const (
TaskRecurrenceNone TaskRecurrence = ""
TaskRecurrenceDaily TaskRecurrence = "daily"
TaskRecurrenceWeekly TaskRecurrence = "weekly"
TaskRecurrenceMonthly TaskRecurrence = "monthly"
TaskRecurrenceYearly TaskRecurrence = "yearly"
)
// TaskModelInterface persists garden work items and generated task instances.
type TaskModelInterface interface {
Insert(task Task) (Task, error)
Get(gardenID, id int) (Task, error)
GetAllForGarden(gardenID int) ([]Task, error)
Update(gardenID int, task Task) (Task, error)
Delete(gardenID, id int) error
}
// ValidateTask applies persistence-independent rules for manual garden tasks.
func ValidateTask(v *validate.Validator, task Task) {
v.Check(strings.TrimSpace(task.Title) != "", "title", "must be provided")
v.Check(len(task.Title) <= 500, "title", "must not be more than 500 bytes long")
v.Check(len(task.Description) <= 10_000, "description", "must not be more than 10000 bytes long")
v.Check(task.Priority >= -100 && task.Priority <= 100, "priority", "must be between -100 and 100")
if task.PlantID != nil {
v.Check(*task.PlantID > 0, "plant_id", "must be a positive integer")
}
if task.LocationID != nil {
v.Check(*task.LocationID > 0, "location_id", "must be a positive integer")
}
if task.DueAtStart != nil && task.DueAtEnd != nil {
v.Check(!task.DueAtStart.After(*task.DueAtEnd), "due_at_end", "must not be before due_at_start")
}
v.Check(task.Recurrence == TaskRecurrenceNone || task.Recurrence == TaskRecurrenceDaily || task.Recurrence == TaskRecurrenceWeekly || task.Recurrence == TaskRecurrenceMonthly || task.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid")
if task.Recurrence != TaskRecurrenceNone {
v.Check(task.DueAtStart != nil || task.DueAtEnd != nil, "recurrence", "requires a due date")
v.Check(task.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero")
}
validateOptionalEnum(v, "plant_status_on_completion", task.PlantStatusOnCompletion, "alive", "dead", "removed", "infested", "harvested")
if task.PlantStatusOnCompletion != nil {
v.Check(task.PlantID != nil, "plant_status_on_completion", "requires a plant")
}
if task.CompletedAt != nil {
v.Check(task.CompletedBy != nil && *task.CompletedBy > 0, "completed_by", "must be set for a completed task")
}
if task.CompletedBy != nil {
v.Check(task.CompletedAt != nil, "completed_at", "must be set when completed_by is set")
}
}
// Task represents a manual or template-generated garden work item.
type Task struct {
ID int `json:"id"`
GardenID int `json:"garden_id"`
PlantID *int `json:"plant_id,omitempty"`
LocationID *int `json:"location_id,omitempty"`
TemplateID *int `json:"template_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
DueAtStart *time.Time `json:"due_at_start,omitempty"`
DueAtEnd *time.Time `json:"due_at_end,omitempty"`
GeneratedFor *time.Time `json:"generated_for,omitempty"`
Recurrence TaskRecurrence `json:"recurrence,omitempty"`
RecurrenceInterval int `json:"recurrence_interval"`
RepeatFromID *int `json:"repeat_from_id,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CompletedBy *int `json:"completed_by,omitempty"`
Priority int `json:"priority"`
Active bool `json:"active"`
CreatedBy int `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int `json:"version"`
Tags []string `json:"tags,omitempty"`
PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
}
+13
View File
@@ -0,0 +1,13 @@
package storage
import (
"time"
"gardomatic.kleiax.de/internal/auth"
)
// TokenModelInterface persists scoped, expiring authentication token hashes.
type TokenModelInterface interface {
New(userID int, ttl time.Duration, scope string) (auth.Token, error)
DeleteAllForUser(scope string, userID int) error
}
+141
View File
@@ -0,0 +1,141 @@
package storage
import (
"time"
"gardomatic.kleiax.de/internal/auth"
"gardomatic.kleiax.de/internal/platform/validate"
)
// UserModelInterface persists accounts and resolves token ownership.
type UserModelInterface interface {
Insert(user User) (User, error)
GetByID(id int) (User, error)
GetByEmail(email string) (User, error)
Update(user User) (User, error)
GetForToken(tokenScope, tokenPlaintext string) (User, error)
CreateEmailChange(userID int, email string, ttl time.Duration) (string, error)
ConfirmEmailChange(tokenPlaintext string, userID int) (User, error)
GetAll() ([]User, error)
UpdateRole(userID int, role ApplicationRole) (User, error)
Delete(userID int) error
}
// ApplicationRole groups garden-independent permissions for an account.
type ApplicationRole string
// ApplicationPermission identifies one garden-independent capability.
type ApplicationPermission string
// Built-in application roles and permissions.
const (
ApplicationRoleUser ApplicationRole = "application:user"
ApplicationRoleAdmin ApplicationRole = "application:admin"
ApplicationPermissionGlobalSpeciesWrite ApplicationPermission = "global_species:write"
ApplicationPermissionUsersManage ApplicationPermission = "users:manage"
ApplicationPermissionSettingsWrite ApplicationPermission = "application_settings:write"
ApplicationPermissionRolesManage ApplicationPermission = "roles:manage"
ApplicationPermissionGardensCreate ApplicationPermission = "gardens:create"
)
// Valid reports whether role is assignable to an account.
func (role ApplicationRole) Valid() bool {
return role == ApplicationRoleUser || role == ApplicationRoleAdmin
}
// Can reports whether an application role grants a permission.
func (role ApplicationRole) Can(permission ApplicationPermission) bool {
if permission == ApplicationPermissionGardensCreate {
return role == ApplicationRoleUser || role == ApplicationRoleAdmin
}
return role == ApplicationRoleAdmin && (permission == ApplicationPermissionGlobalSpeciesWrite ||
permission == ApplicationPermissionUsersManage ||
permission == ApplicationPermissionSettingsWrite ||
permission == ApplicationPermissionRolesManage)
}
// Permissions returns the capabilities bundled into the role.
func (role ApplicationRole) Permissions() []ApplicationPermission {
permissions := []ApplicationPermission{}
for _, permission := range []ApplicationPermission{
ApplicationPermissionGlobalSpeciesWrite,
ApplicationPermissionUsersManage,
ApplicationPermissionSettingsWrite,
ApplicationPermissionRolesManage,
ApplicationPermissionGardensCreate,
} {
if role.Can(permission) {
permissions = append(permissions, permission)
}
}
return permissions
}
// AllApplicationPermissions returns a copy of the supported application-level
// permissions.
func AllApplicationPermissions() []ApplicationPermission {
return []ApplicationPermission{
ApplicationPermissionGlobalSpeciesWrite,
ApplicationPermissionUsersManage,
ApplicationPermissionSettingsWrite,
ApplicationPermissionRolesManage,
ApplicationPermissionGardensCreate,
}
}
// ValidApplicationPermission reports whether permission is application-scoped
// or the global wildcard.
func ValidApplicationPermission(permission string) bool {
for _, candidate := range AllApplicationPermissions() {
if string(candidate) == permission {
return true
}
}
return permission == "*"
}
// User is an account that can own or join gardens.
type User struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Email string `json:"email"`
Color string `json:"color"`
Password auth.Password `json:"-"`
Activated bool `json:"activated"`
Role ApplicationRole `json:"role"`
Permissions []ApplicationPermission `json:"permissions"`
Version int `json:"-"`
}
// Can reports whether the user's resolved permissions grant permission. It
// falls back to the built-in role only when no resolved list is present.
func (user User) Can(permission ApplicationPermission) bool {
if user.Permissions != nil {
for _, granted := range user.Permissions {
if granted == permission || granted == "*" {
return true
}
}
return false
}
return user.Role.Can(permission)
}
// ValidateEmail applies the accepted email-address rules.
func ValidateEmail(v *validate.Validator, email string) {
v.Check(email != "", "email", "must be provided")
v.Check(validate.Matches(email, validate.EmailRX), "email", "must be a valid email address")
}
// ValidateUser applies account and password validation rules.
func ValidateUser(v *validate.Validator, user User) {
v.Check(user.Name != "", "name", "must be provided")
v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long")
ValidateEmail(v, user.Email)
v.Check(user.Color == "" || validate.Matches(user.Color, validate.ColorRX), "color", "must be a valid hex color")
user.Password.Validate(v)
}