@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package storage defines Gardomatic persistence models and the interfaces
|
||||
// implemented by database-specific adapters.
|
||||
package storage
|
||||
@@ -0,0 +1,10 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDuplicateEmail indicates that a user email is already registered.
|
||||
ErrDuplicateEmail = errors.New("models: duplicate email")
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package postgres implements the Gardomatic storage interfaces for PostgreSQL.
|
||||
package postgres
|
||||
@@ -0,0 +1,126 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenInviteModel stores garden invitations in PostgreSQL.
|
||||
// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL.
|
||||
type GardenInviteModel struct{ DB *sql.DB }
|
||||
|
||||
// Upsert creates or replaces a pending invitation for a garden and email.
|
||||
func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
|
||||
invite.Token = rand.Text()
|
||||
hash := sha256.Sum256([]byte(invite.Token))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at)
|
||||
SELECT $1,$2,$3,$4,$5,$6
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET
|
||||
role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by,
|
||||
expires_at=EXCLUDED.expires_at, created_at=now()
|
||||
RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
func scanInvite(row scanner) (storage.GardenInvite, error) {
|
||||
var invite storage.GardenInvite
|
||||
err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
// GetByToken returns an unexpired pending invitation by its plaintext token.
|
||||
func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenInvite{}, recordError(err)
|
||||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists pending invitations for a garden.
|
||||
func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.GardenInvite{}
|
||||
for rows.Next() {
|
||||
invite, err := scanInvite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, invite)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Delete revokes an invitation within its garden.
|
||||
func (m GardenInviteModel) Delete(gardenID, inviteID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept consumes an invitation and creates or updates membership in one
|
||||
// transaction so a token cannot be accepted twice concurrently.
|
||||
func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role}
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = GardenMemberModel(m).loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenMemberModel stores garden memberships in PostgreSQL.
|
||||
// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
|
||||
type GardenMemberModel struct{ DB *sql.DB }
|
||||
|
||||
func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
|
||||
UNION ALL
|
||||
SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
|
||||
ORDER BY granted DESC`, member.Role, member.GardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
base := []string{}
|
||||
overrides := []storage.GardenRolePermissionOverride{}
|
||||
for rows.Next() {
|
||||
var permission storage.GardenPermission
|
||||
var granted bool
|
||||
if err := rows.Scan(&permission, &granted); err != nil {
|
||||
return err
|
||||
}
|
||||
if granted {
|
||||
base = append(base, string(permission))
|
||||
} else {
|
||||
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
member.Permissions = storage.ResolveGardenPermissions(base, overrides)
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGardenMember(s scanner) (storage.GardenMember, error) {
|
||||
var member storage.GardenMember
|
||||
err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
|
||||
return member, err
|
||||
}
|
||||
|
||||
// Insert adds a user to a garden.
|
||||
func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
SELECT $1, $2, $3
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
|
||||
).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Get returns a user's membership and effective permissions in a garden.
|
||||
func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
|
||||
SELECT garden_id, user_id, role, joined_at
|
||||
FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists garden members with effective permissions.
|
||||
func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
|
||||
FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
|
||||
ORDER BY joined_at, user_id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []storage.GardenMember{}
|
||||
for rows.Next() {
|
||||
var member storage.GardenMember
|
||||
err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, member)
|
||||
if err = m.loadPermissions(&members[len(members)-1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a member's garden role.
|
||||
func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
var current storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return storage.GardenMember{}, countErr
|
||||
} else if count == 0 {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Delete removes a non-owner membership from a garden.
|
||||
func (m GardenMemberModel) Delete(gardenID, userID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var role storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if role == storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
|
||||
return err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransferOwnership swaps owner and member roles atomically while preserving
|
||||
// the invariant that a garden always has one owner.
|
||||
func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
|
||||
if fromUserID == toUserID {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var fromRole, toRole storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if fromRole != storage.GardenRoleOwner {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenModel stores gardens in PostgreSQL.
|
||||
// GardenModel implements storage.GardenModelInterface for PostgreSQL.
|
||||
type GardenModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert creates a garden and its owner membership atomically.
|
||||
func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
INSERT INTO gardens (name, description, image_data, image_id)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, created_at, updated_at, version`
|
||||
err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan(
|
||||
&garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner)
|
||||
if err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
func scanGarden(s scanner) (storage.Garden, error) {
|
||||
var garden storage.Garden
|
||||
err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version)
|
||||
return garden, err
|
||||
}
|
||||
|
||||
// Get returns a garden by ID.
|
||||
func (m GardenModel) Get(id int) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
garden, err := scanGarden(m.DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version
|
||||
FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id))
|
||||
if err != nil {
|
||||
return storage.Garden{}, recordError(err)
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// GetAllForUser lists gardens visible to a user with resolved permissions.
|
||||
func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role
|
||||
FROM gardens g
|
||||
INNER JOIN garden_members gm ON gm.garden_id = g.id
|
||||
LEFT JOIN images i ON i.id=g.image_id
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY g.name, g.id`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
gardens := []storage.Garden{}
|
||||
for rows.Next() {
|
||||
var garden storage.Garden
|
||||
err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gardens = append(gardens, garden)
|
||||
}
|
||||
return gardens, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a garden using optimistic locking.
|
||||
func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE gardens
|
||||
SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $4 AND version = $5
|
||||
RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version,
|
||||
).Scan(&garden.UpdatedAt, &garden.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Garden{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Garden{}, err
|
||||
}
|
||||
return garden, nil
|
||||
}
|
||||
|
||||
// Delete removes a garden and its dependent records.
|
||||
func (m GardenModel) Delete(id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const queryTimeout = 3 * time.Second
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func contextWithTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
func jsonValue(value json.RawMessage) json.RawMessage {
|
||||
if len(value) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func deleteByID(db *sql.DB, query string, args ...any) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
result, err := db.ExecContext(ctx, query, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordError(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
var pqError *pq.Error
|
||||
if errors.As(err, &pqError) && pqError.Code == "23505" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if errors.As(err, &pqError) && pqError.Code == "23503" {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableUserID(id int) any {
|
||||
if id < 1 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image
|
||||
// retrieval is always constrained by the owning garden.
|
||||
type ImageModel struct{ DB *sql.DB }
|
||||
|
||||
// Insert stores an image in its garden's library.
|
||||
func (m ImageModel) Insert(image storage.Image) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
sum := sha256.Sum256(image.Data)
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName,
|
||||
image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
image.Data = nil
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// Get returns an image within its garden.
|
||||
func (m ImageModel) Get(gardenID, id int) (storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var image storage.Image
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan(
|
||||
&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data)
|
||||
if err != nil {
|
||||
return storage.Image{}, recordError(err)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists image metadata matching filter.
|
||||
func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at
|
||||
FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.Image{}
|
||||
for rows.Next() {
|
||||
var image storage.Image
|
||||
if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, image)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CountForGarden returns the number of images in a garden library.
|
||||
func (m ImageModel) CountForGarden(gardenID int) (int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var count int
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RecordAssignment appends an entity image-change history record.
|
||||
func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error {
|
||||
if sameImage(c.PreviousImageID, c.ImageID) {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
_, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy))
|
||||
return err
|
||||
}
|
||||
|
||||
func sameImage(a, b *int) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// JournalModel implements storage.JournalModelInterface for PostgreSQL and
|
||||
// enforces the garden boundary on entries and attachments.
|
||||
type JournalModel struct{ DB *sql.DB }
|
||||
|
||||
const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version`
|
||||
|
||||
func scanJournalEntry(s scanner) (storage.JournalEntry, error) {
|
||||
var entry storage.JournalEntry
|
||||
err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// Insert creates a journal entry and its tags atomically.
|
||||
func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entry.EntryType == "" {
|
||||
entry.EntryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Get returns an entry with tags and attachment metadata within its garden.
|
||||
func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, recordError(err)
|
||||
}
|
||||
entry.Attachments, err = m.attachments(ctx, entry.ID)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
// GetAllForGarden lists entries of an optional type in a garden.
|
||||
func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
if entryType == "" {
|
||||
entryType = storage.JournalEntryTypeJournal
|
||||
}
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []storage.JournalEntry{}
|
||||
for rows.Next() {
|
||||
entry, scanErr := scanJournalEntry(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].Attachments, err = m.attachments(ctx, entries[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Update changes an entry and its tags atomically using optimistic locking.
|
||||
func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.JournalEntry{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.JournalEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Delete removes an entry within its garden.
|
||||
func (m JournalModel) Delete(gardenID, id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id)
|
||||
}
|
||||
|
||||
// InsertAttachment adds inline media or a library-image link to an entry.
|
||||
func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var entryType storage.JournalEntryType
|
||||
if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
if attachment.ImageID != nil {
|
||||
image, err := ImageModel(m).Get(gardenID, *attachment.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.MediaType, attachment.Size = image.MediaType, image.Size
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = image.FileName
|
||||
}
|
||||
if attachment.FileName == "" {
|
||||
attachment.FileName = "Bild"
|
||||
}
|
||||
} else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" {
|
||||
image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)})
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, err
|
||||
}
|
||||
attachment.ImageID = &image.ID
|
||||
}
|
||||
data := attachment.Data
|
||||
if attachment.ImageID != nil {
|
||||
data = []byte{}
|
||||
}
|
||||
size := int64(len(attachment.Data))
|
||||
if attachment.ImageID != nil {
|
||||
size = attachment.Size
|
||||
}
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
attachment.EntryID, attachment.Size = entryID, size
|
||||
attachment.Data = nil
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// GetAttachment returns attachment metadata and data within its garden and entry.
|
||||
func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var a storage.JournalAttachment
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID)
|
||||
if err != nil {
|
||||
return storage.JournalAttachment{}, recordError(err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAttachment removes an attachment within its garden and entry.
|
||||
func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID)
|
||||
}
|
||||
|
||||
func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) {
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.JournalAttachment{}
|
||||
for rows.Next() {
|
||||
var a storage.JournalAttachment
|
||||
if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var userID, gardenID, foreignGardenID int
|
||||
if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
|
||||
model := JournalModel{DB: db}
|
||||
entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert entry: %v", err)
|
||||
}
|
||||
foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign entry: %v", err)
|
||||
}
|
||||
if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign lookup: %v", err)
|
||||
}
|
||||
pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard entry: %v", err)
|
||||
}
|
||||
|
||||
tags := TagModel{DB: db}
|
||||
storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"})
|
||||
if err != nil || len(storedTags) != 2 {
|
||||
t.Fatalf("set tags: %#v, %v", storedTags, err)
|
||||
}
|
||||
attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert attachment: %v", err)
|
||||
}
|
||||
pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")})
|
||||
if err != nil {
|
||||
t.Fatalf("insert pinboard attachment: %v", err)
|
||||
}
|
||||
if pinboardAttachment.ImageID == nil {
|
||||
t.Fatal("pinboard image was not added to the shared image library")
|
||||
}
|
||||
pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID)
|
||||
if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) {
|
||||
t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err)
|
||||
}
|
||||
loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID)
|
||||
if err != nil || string(loaded.Data) != "jpeg" {
|
||||
t.Fatalf("load attachment: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign attachment lookup: %v", err)
|
||||
}
|
||||
|
||||
entry.Title = "Große Ernte"
|
||||
updated, err := model.Update(gardenID, entry)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("update entry: %#v, %v", updated, err)
|
||||
}
|
||||
listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal)
|
||||
if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" {
|
||||
t.Fatalf("list entries: %#v, %v", listed, err)
|
||||
}
|
||||
pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard)
|
||||
if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 {
|
||||
t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err)
|
||||
}
|
||||
if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil {
|
||||
t.Fatalf("delete attachment: %v", err)
|
||||
}
|
||||
if err = model.Delete(gardenID, entry.ID); err != nil {
|
||||
t.Fatalf("delete entry: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// LocationModel stores garden locations in PostgreSQL.
|
||||
// LocationModel implements storage.LocationModelInterface for PostgreSQL and
|
||||
// scopes hierarchical locations to their garden.
|
||||
type LocationModel struct{ DB *sql.DB }
|
||||
|
||||
const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm,
|
||||
l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version,
|
||||
COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)`
|
||||
|
||||
func scanLocation(s scanner) (storage.Location, error) {
|
||||
var location storage.Location
|
||||
err := s.Scan(
|
||||
&location.ID, &location.GardenID, &location.ParentID, &location.Name,
|
||||
&location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction,
|
||||
&location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy,
|
||||
)
|
||||
return location, err
|
||||
}
|
||||
|
||||
// Insert creates a location in its garden.
|
||||
func (m LocationModel) Insert(location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO locations
|
||||
(garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
location.GardenID, location.ParentID, location.Name, location.Description,
|
||||
location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy),
|
||||
).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Get returns a location within its garden.
|
||||
func (m LocationModel) Get(gardenID, id int) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.Location{}, recordError(err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists locations in a garden.
|
||||
func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+`
|
||||
FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
locations := []storage.Location{}
|
||||
for rows.Next() {
|
||||
location, err := scanLocation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations = append(locations, location)
|
||||
}
|
||||
return locations, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a location using optimistic locking.
|
||||
func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4,
|
||||
area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id = $12 AND id = $13 AND version = $14
|
||||
RETURNING updated_at, version`,
|
||||
location.ParentID, location.Name, location.Description, location.Kind,
|
||||
location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy),
|
||||
gardenID, location.ID, location.Version,
|
||||
).Scan(&location.UpdatedAt, &location.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Location{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Location{}, err
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Delete removes a location within its garden.
|
||||
func (m LocationModel) Delete(gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var gardenID, foreignGardenID int
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) })
|
||||
|
||||
locations := LocationModel{DB: db}
|
||||
local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local location: %v", err)
|
||||
}
|
||||
foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign location: %v", err)
|
||||
}
|
||||
if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
|
||||
var plantID int
|
||||
if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID)
|
||||
if err != nil {
|
||||
t.Fatalf("list plants with planter join: %v", err)
|
||||
}
|
||||
if len(plants) != 1 || plants[0].ID != plantID {
|
||||
t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID)
|
||||
}
|
||||
assignments := PlantLocationModel{DB: db}
|
||||
created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("insert local assignment: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("local assignment has no id")
|
||||
}
|
||||
if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err)
|
||||
}
|
||||
listed, err := assignments.GetAllForPlant(gardenID, plantID)
|
||||
if err != nil || len(listed) != 1 {
|
||||
t.Fatalf("list assignments: values=%+v err=%v", listed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
DROP TABLE plant_status_history;
|
||||
DROP TABLE application_settings;
|
||||
DROP TABLE journal_attachments;
|
||||
DROP TABLE journal_entry_tags;
|
||||
DROP TABLE journal_entries;
|
||||
DROP TABLE entity_image_history;
|
||||
|
||||
ALTER TABLE locations DROP COLUMN image_id;
|
||||
ALTER TABLE plants DROP COLUMN image_id;
|
||||
ALTER TABLE species DROP COLUMN image_id;
|
||||
ALTER TABLE gardens DROP COLUMN image_id;
|
||||
DROP TABLE images;
|
||||
|
||||
DROP TABLE species_tags;
|
||||
DROP TABLE plant_tags;
|
||||
DROP TABLE task_tags;
|
||||
DROP TABLE tags;
|
||||
DROP TABLE task_priorities;
|
||||
DROP TABLE task_template_opt_outs;
|
||||
DROP TABLE tasks;
|
||||
DROP TABLE plant_locations;
|
||||
DROP TABLE plants;
|
||||
DROP TABLE locations;
|
||||
DROP TABLE species_task_templates;
|
||||
DROP TABLE care_instructions;
|
||||
DROP TABLE species;
|
||||
DROP TABLE species_categories;
|
||||
DROP TABLE garden_role_permission_overrides;
|
||||
DROP TABLE garden_invites;
|
||||
DROP TABLE garden_members;
|
||||
|
||||
ALTER TABLE users DROP CONSTRAINT users_application_role_fkey;
|
||||
DROP TABLE role_permissions;
|
||||
DROP TABLE roles;
|
||||
DROP TABLE gardens;
|
||||
DROP TABLE user_email_changes;
|
||||
DROP TABLE tokens;
|
||||
DROP TABLE sessions;
|
||||
DROP TABLE users;
|
||||
|
||||
DROP TYPE task_template_origin;
|
||||
DROP TYPE task_trigger_type;
|
||||
DROP TYPE care_instruction_status;
|
||||
DROP TYPE plant_status;
|
||||
DROP TYPE plant_lifecycle;
|
||||
DROP TYPE soil_reaction;
|
||||
DROP TYPE soil_condition;
|
||||
DROP TYPE sun_exposure;
|
||||
DROP EXTENSION citext;
|
||||
@@ -0,0 +1,524 @@
|
||||
-- Extensions and domain types
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade');
|
||||
CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy');
|
||||
CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral');
|
||||
CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial');
|
||||
CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested');
|
||||
CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned');
|
||||
CREATE TYPE task_trigger_type AS ENUM (
|
||||
'month_of_year',
|
||||
'relative_to_planting',
|
||||
'relative_to_last_task',
|
||||
'relative_to_sowing',
|
||||
'relative_to_harvest',
|
||||
'relative_to_species_planting'
|
||||
);
|
||||
CREATE TYPE task_template_origin AS ENUM (
|
||||
'manual',
|
||||
'season_sowing',
|
||||
'season_planting',
|
||||
'season_harvest'
|
||||
);
|
||||
|
||||
-- Authentication and users
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
email citext NOT NULL UNIQUE,
|
||||
password_hash bytea NOT NULL,
|
||||
activated boolean NOT NULL,
|
||||
application_role text NOT NULL DEFAULT 'application:user',
|
||||
color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int],
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token text PRIMARY KEY,
|
||||
data bytea NOT NULL,
|
||||
expiry timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_expiry_idx ON sessions (expiry);
|
||||
|
||||
CREATE TABLE tokens (
|
||||
hash bytea PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expiry timestamptz NOT NULL,
|
||||
scope text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_email_changes (
|
||||
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL UNIQUE,
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Gardens, roles, and permissions
|
||||
CREATE TABLE gardens (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
name text PRIMARY KEY,
|
||||
scope text NOT NULL CHECK (scope IN ('application', 'garden')),
|
||||
label text NOT NULL,
|
||||
system boolean NOT NULL DEFAULT false,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT roles_scope_garden_check CHECK (
|
||||
(scope = 'application' AND garden_id IS NULL) OR scope = 'garden'
|
||||
)
|
||||
);
|
||||
CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
PRIMARY KEY (role_name, permission)
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_application_role_fkey
|
||||
FOREIGN KEY (application_role) REFERENCES roles(name);
|
||||
|
||||
CREATE TABLE garden_members (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (garden_id, user_id)
|
||||
);
|
||||
CREATE INDEX garden_members_user_id_idx ON garden_members (user_id);
|
||||
|
||||
CREATE TABLE garden_invites (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member' REFERENCES roles(name),
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX garden_invites_pending_email_unique
|
||||
ON garden_invites (garden_id, email) WHERE accepted_at IS NULL;
|
||||
CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at);
|
||||
|
||||
CREATE TABLE garden_role_permission_overrides (
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
permission text NOT NULL,
|
||||
granted boolean NOT NULL,
|
||||
PRIMARY KEY (garden_id, role_name, permission)
|
||||
);
|
||||
|
||||
-- Species catalogue and care instructions
|
||||
CREATE TABLE species_categories (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
lifecycle plant_lifecycle,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name));
|
||||
|
||||
CREATE TABLE species (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT,
|
||||
common_name text NOT NULL,
|
||||
cultivar text NOT NULL DEFAULT '',
|
||||
botanical_name text NOT NULL DEFAULT '',
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
winter_protection text,
|
||||
spacing_cm integer,
|
||||
height_cm integer,
|
||||
sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12),
|
||||
sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31),
|
||||
sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12),
|
||||
sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31),
|
||||
planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12),
|
||||
planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31),
|
||||
planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12),
|
||||
planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31),
|
||||
harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12),
|
||||
harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31),
|
||||
harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12),
|
||||
harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31),
|
||||
notes text NOT NULL DEFAULT '',
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX species_global_unique
|
||||
ON species (common_name, cultivar) WHERE garden_id IS NULL;
|
||||
CREATE UNIQUE INDEX species_garden_unique
|
||||
ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE care_instructions (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
text text NOT NULL,
|
||||
status care_instruction_status NOT NULL DEFAULT 'untested',
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
updated_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX care_instructions_species ON care_instructions(species_id);
|
||||
|
||||
CREATE TABLE species_task_templates (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
trigger_type task_trigger_type NOT NULL,
|
||||
month_from smallint CHECK (month_from BETWEEN 1 AND 12),
|
||||
day_from smallint CHECK (day_from BETWEEN 1 AND 31),
|
||||
month_to smallint CHECK (month_to BETWEEN 1 AND 12),
|
||||
day_to smallint CHECK (day_to BETWEEN 1 AND 31),
|
||||
offset_days_from integer,
|
||||
offset_days_to integer,
|
||||
interval_days smallint,
|
||||
trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0),
|
||||
trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')),
|
||||
duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0),
|
||||
duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')),
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
origin task_template_origin NOT NULL DEFAULT 'manual',
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to)
|
||||
);
|
||||
CREATE INDEX species_task_templates_species_id_idx
|
||||
ON species_task_templates (species_id) WHERE active = true;
|
||||
CREATE UNIQUE INDEX species_task_templates_derived_origin_key
|
||||
ON species_task_templates (species_id, origin) WHERE origin <> 'manual';
|
||||
|
||||
-- Locations, plants, and tasks
|
||||
CREATE TABLE locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
parent_id bigint REFERENCES locations(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
kind text NOT NULL DEFAULT '',
|
||||
area_sqm numeric(10,2),
|
||||
sun_exposure sun_exposure,
|
||||
soil_condition soil_condition,
|
||||
soil_reaction soil_reaction,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX locations_garden_id_idx ON locations (garden_id);
|
||||
CREATE INDEX locations_parent_id_idx ON locations (parent_id);
|
||||
|
||||
CREATE TABLE plants (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
species_id bigint REFERENCES species(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
acquired_at date,
|
||||
status plant_status NOT NULL DEFAULT 'alive',
|
||||
removed_at date,
|
||||
attributes jsonb NOT NULL DEFAULT '{}',
|
||||
image_data text NOT NULL DEFAULT '',
|
||||
created_by bigint REFERENCES users(id),
|
||||
updated_by bigint REFERENCES users(id),
|
||||
planted_by bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive';
|
||||
|
||||
CREATE TABLE plant_locations (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||
quantity integer NOT NULL DEFAULT 1,
|
||||
planted_at date,
|
||||
removed_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX plant_location_active
|
||||
ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL;
|
||||
CREATE INDEX plant_locations_location_id_idx
|
||||
ON plant_locations (location_id) WHERE removed_at IS NULL;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
plant_id bigint REFERENCES plants(id) ON DELETE CASCADE,
|
||||
location_id bigint REFERENCES locations(id) ON DELETE CASCADE,
|
||||
template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
due_at_start timestamptz,
|
||||
due_at_end timestamptz,
|
||||
generated_for date,
|
||||
completed_at timestamptz,
|
||||
completed_by bigint REFERENCES users(id),
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')),
|
||||
recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0),
|
||||
repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
plant_status_on_completion plant_status,
|
||||
created_by bigint NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end),
|
||||
CONSTRAINT tasks_completion_user_check CHECK (
|
||||
(completed_at IS NULL AND completed_by IS NULL) OR
|
||||
(completed_at IS NOT NULL AND completed_by IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX tasks_garden_id_due_at_end_idx
|
||||
ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_garden_id_due_at_start_idx
|
||||
ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL;
|
||||
CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_repeat_from_unique
|
||||
ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX tasks_template_slot_unique
|
||||
ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE task_template_opt_outs (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (plant_id, template_id)
|
||||
);
|
||||
|
||||
CREATE TABLE task_priorities (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
name text NOT NULL UNIQUE,
|
||||
value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Tags
|
||||
CREATE TABLE tags (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (garden_id, name)
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL;
|
||||
|
||||
CREATE TABLE task_tags (
|
||||
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
CREATE TABLE plant_tags (
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (plant_id, tag_id)
|
||||
);
|
||||
CREATE TABLE species_tags (
|
||||
species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (species_id, tag_id)
|
||||
);
|
||||
|
||||
-- Image library
|
||||
CREATE TABLE images (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
file_name text NOT NULL DEFAULT '',
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
checksum text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'upload',
|
||||
created_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum);
|
||||
|
||||
ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE entity_image_history (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')),
|
||||
entity_id bigint NOT NULL,
|
||||
previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
image_id bigint REFERENCES images(id) ON DELETE SET NULL,
|
||||
changed_by bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX entity_image_history_entity_idx
|
||||
ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC);
|
||||
|
||||
-- Journal
|
||||
CREATE TABLE journal_entries (
|
||||
id bigserial PRIMARY KEY,
|
||||
garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE,
|
||||
author_id bigint NOT NULL REFERENCES users(id),
|
||||
entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')),
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX journal_entries_garden_created_idx
|
||||
ON journal_entries(garden_id, created_at DESC, id DESC);
|
||||
CREATE INDEX journal_entries_garden_type_created_idx
|
||||
ON journal_entries(garden_id, entry_type, created_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE journal_entry_tags (
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (journal_entry_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE journal_attachments (
|
||||
id bigserial PRIMARY KEY,
|
||||
journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
|
||||
image_id bigint REFERENCES images(id) ON DELETE RESTRICT,
|
||||
file_name text NOT NULL,
|
||||
media_type text NOT NULL,
|
||||
data bytea NOT NULL,
|
||||
size bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id);
|
||||
|
||||
-- Application settings and lifecycle history
|
||||
CREATE TABLE application_settings (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
lifecycle_status_enabled boolean NOT NULL DEFAULT false,
|
||||
lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12),
|
||||
lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31),
|
||||
timezone text NOT NULL DEFAULT 'Europe/Berlin',
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE plant_status_history (
|
||||
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
|
||||
from_status plant_status NOT NULL,
|
||||
to_status plant_status NOT NULL,
|
||||
reason text NOT NULL,
|
||||
effective_at date NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX plant_status_history_plant_id_idx
|
||||
ON plant_status_history (plant_id, created_at DESC);
|
||||
|
||||
-- Initial reference data
|
||||
INSERT INTO roles (name, scope, label, system) VALUES
|
||||
('application:user', 'application', 'Nutzer', true),
|
||||
('application:admin', 'application', 'Administrator', true),
|
||||
('owner', 'garden', 'Eigentümer', true),
|
||||
('admin', 'garden', 'Administrator', true),
|
||||
('member', 'garden', 'Mitglied', true),
|
||||
('worker', 'garden', 'Mitarbeiter', true),
|
||||
('viewer', 'garden', 'Leser', true);
|
||||
|
||||
INSERT INTO role_permissions (role_name, permission) VALUES
|
||||
('application:user', 'gardens:create'),
|
||||
('application:admin', 'gardens:create'),
|
||||
('application:admin', 'global_species:write'),
|
||||
('application:admin', 'users:manage'),
|
||||
('application:admin', 'application_settings:write'),
|
||||
('application:admin', 'roles:manage'),
|
||||
('owner', '*'),
|
||||
('admin', 'garden:*'),
|
||||
('member', 'garden:read'),
|
||||
('member', 'content:write'),
|
||||
('member', 'plants:create'),
|
||||
('member', 'plants:read:own'),
|
||||
('member', 'plants:read:other'),
|
||||
('member', 'plants:update:own'),
|
||||
('member', 'plants:delete:own'),
|
||||
('member', 'locations:create'),
|
||||
('member', 'locations:read:own'),
|
||||
('member', 'locations:read:other'),
|
||||
('member', 'locations:update:own'),
|
||||
('member', 'locations:delete:own'),
|
||||
('member', 'tasks:create'),
|
||||
('member', 'tasks:read:own'),
|
||||
('member', 'tasks:read:other'),
|
||||
('member', 'tasks:update:own'),
|
||||
('member', 'tasks:delete:own'),
|
||||
('member', 'tasks:complete:own'),
|
||||
('member', 'tasks:complete:other'),
|
||||
('worker', 'garden:read'),
|
||||
('worker', 'tasks:read:own'),
|
||||
('worker', 'tasks:read:other'),
|
||||
('worker', 'tasks:complete:own'),
|
||||
('worker', 'tasks:complete:other'),
|
||||
('viewer', 'garden:read'),
|
||||
('viewer', 'plants:read:own'),
|
||||
('viewer', 'plants:read:other'),
|
||||
('viewer', 'locations:read:own'),
|
||||
('viewer', 'locations:read:other'),
|
||||
('viewer', 'tasks:read:own'),
|
||||
('viewer', 'tasks:read:other');
|
||||
|
||||
INSERT INTO task_priorities (name, value, sort_order) VALUES
|
||||
('Niedrig', -5, 10),
|
||||
('Normal', 0, 20),
|
||||
('Erhöht', 3, 30),
|
||||
('Hoch', 5, 40);
|
||||
|
||||
INSERT INTO application_settings (singleton) VALUES (true);
|
||||
|
||||
INSERT INTO species_categories (name, sort_order) VALUES
|
||||
('Gehölz', 10),
|
||||
('Gemüse', 20),
|
||||
('Kraut', 30),
|
||||
('Obst', 40),
|
||||
('Staude', 50);
|
||||
@@ -0,0 +1,33 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// New binds all PostgreSQL model implementations to db.
|
||||
func New(db *sql.DB) storage.Models {
|
||||
return storage.Models{
|
||||
ApplicationSettings: ApplicationSettingsModel{DB: db},
|
||||
Roles: RoleModel{DB: db},
|
||||
Gardens: GardenModel{DB: db},
|
||||
GardenMembers: GardenMemberModel{DB: db},
|
||||
GardenInvites: GardenInviteModel{DB: db},
|
||||
Locations: LocationModel{DB: db},
|
||||
Plants: PlantModel{DB: db},
|
||||
PlantLocations: PlantLocationModel{DB: db},
|
||||
Species: SpeciesModel{DB: db},
|
||||
CareInstructions: CareInstructionModel{DB: db},
|
||||
SpeciesCategories: SpeciesCategoryModel{DB: db},
|
||||
TaskPriorities: TaskPriorityModel{DB: db},
|
||||
SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db},
|
||||
Tasks: TaskModel{DB: db},
|
||||
Journal: JournalModel{DB: db},
|
||||
Images: ImageModel{DB: db},
|
||||
Tags: TagModel{DB: db},
|
||||
TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db},
|
||||
Tokens: TokenModel{DB: db},
|
||||
Users: UserModel{DB: db},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// PlantLocationModel stores plant-to-location assignments in PostgreSQL.
|
||||
// PlantLocationModel implements storage.PlantLocationModelInterface for
|
||||
// PostgreSQL and verifies both plant and location membership in the garden.
|
||||
type PlantLocationModel struct{ DB *sql.DB }
|
||||
|
||||
const plantLocationQualifiedColumns = `pl.id, pl.plant_id, pl.location_id, pl.quantity, pl.planted_at,
|
||||
pl.removed_at, pl.notes, pl.created_at, pl.version`
|
||||
|
||||
func scanPlantLocation(s scanner) (storage.PlantLocation, error) {
|
||||
var plantLocation storage.PlantLocation
|
||||
err := s.Scan(
|
||||
&plantLocation.ID, &plantLocation.PlantID, &plantLocation.LocationID,
|
||||
&plantLocation.Quantity, &plantLocation.PlantedAt, &plantLocation.RemovedAt,
|
||||
&plantLocation.Notes, &plantLocation.CreatedAt, &plantLocation.Version,
|
||||
)
|
||||
return plantLocation, err
|
||||
}
|
||||
|
||||
// Insert assigns a plant to a location in the same garden.
|
||||
func (m PlantLocationModel) Insert(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO plant_locations
|
||||
(plant_id, location_id, quantity, planted_at, removed_at, notes)
|
||||
SELECT $2, $3, $4, $5, $6, $7
|
||||
FROM plants p, locations l
|
||||
WHERE p.id = $2 AND p.garden_id = $1 AND l.id = $3 AND l.garden_id = $1
|
||||
RETURNING id, created_at, version`,
|
||||
gardenID, plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
|
||||
plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
|
||||
).Scan(&plantLocation.ID, &plantLocation.CreatedAt, &plantLocation.Version)
|
||||
if err != nil {
|
||||
return storage.PlantLocation{}, recordError(err)
|
||||
}
|
||||
return plantLocation, nil
|
||||
}
|
||||
|
||||
// Get returns a plant assignment within its garden.
|
||||
func (m PlantLocationModel) Get(gardenID, id int) (storage.PlantLocation, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
plantLocation, err := scanPlantLocation(m.DB.QueryRowContext(ctx,
|
||||
`SELECT `+plantLocationQualifiedColumns+` FROM plant_locations pl
|
||||
JOIN plants p ON p.id = pl.plant_id WHERE p.garden_id = $1 AND pl.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.PlantLocation{}, recordError(err)
|
||||
}
|
||||
return plantLocation, nil
|
||||
}
|
||||
|
||||
func (m PlantLocationModel) getAll(query string, gardenID, id int) ([]storage.PlantLocation, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, query, gardenID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
plantLocations := []storage.PlantLocation{}
|
||||
for rows.Next() {
|
||||
plantLocation, err := scanPlantLocation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plantLocations = append(plantLocations, plantLocation)
|
||||
}
|
||||
return plantLocations, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllForPlant lists a plant's current and historical placements.
|
||||
func (m PlantLocationModel) GetAllForPlant(gardenID, plantID int) ([]storage.PlantLocation, error) {
|
||||
return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
|
||||
FROM plant_locations pl JOIN plants p ON p.id = pl.plant_id
|
||||
WHERE p.garden_id = $1 AND pl.plant_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, plantID)
|
||||
}
|
||||
|
||||
// GetAllForLocation lists current and historical plant placements at a location.
|
||||
func (m PlantLocationModel) GetAllForLocation(gardenID, locationID int) ([]storage.PlantLocation, error) {
|
||||
return m.getAll(`SELECT `+plantLocationQualifiedColumns+`
|
||||
FROM plant_locations pl JOIN locations l ON l.id = pl.location_id
|
||||
WHERE l.garden_id = $1 AND pl.location_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, locationID)
|
||||
}
|
||||
|
||||
// Update changes a plant assignment using optimistic locking.
|
||||
func (m PlantLocationModel) Update(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `
|
||||
UPDATE plant_locations SET plant_id = $1, location_id = $2, quantity = $3,
|
||||
planted_at = $4, removed_at = $5, notes = $6, version = version + 1
|
||||
WHERE id = $7 AND version = $8
|
||||
AND EXISTS (SELECT 1 FROM plants p WHERE p.id = plant_locations.plant_id AND p.garden_id = $9)
|
||||
AND EXISTS (SELECT 1 FROM plants p WHERE p.id = $1 AND p.garden_id = $9)
|
||||
AND EXISTS (SELECT 1 FROM locations l WHERE l.id = $2 AND l.garden_id = $9)`,
|
||||
plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity,
|
||||
plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes,
|
||||
plantLocation.ID, plantLocation.Version, gardenID)
|
||||
if err != nil {
|
||||
return storage.PlantLocation{}, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return storage.PlantLocation{}, err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return storage.PlantLocation{}, storage.ErrEditConflict
|
||||
}
|
||||
plantLocation.Version++
|
||||
return plantLocation, nil
|
||||
}
|
||||
|
||||
// Delete removes a plant assignment within its garden.
|
||||
func (m PlantLocationModel) Delete(gardenID, id int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM plant_locations pl USING plants p
|
||||
WHERE pl.id = $2 AND p.id = pl.plant_id AND p.garden_id = $1`, gardenID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// PlantModel stores garden-scoped plant instances in PostgreSQL.
|
||||
// PlantModel implements storage.PlantModelInterface for PostgreSQL and scopes
|
||||
// every concrete plant operation to its garden.
|
||||
type PlantModel struct{ DB *sql.DB }
|
||||
|
||||
const plantColumns = `p.id, p.garden_id, p.species_id, p.name, p.notes, p.acquired_at, p.status,
|
||||
p.removed_at, p.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), p.image_id, p.created_at, p.updated_at, p.version,
|
||||
COALESCE(p.created_by, 0), COALESCE(p.updated_by, 0), COALESCE(p.planted_by, 0), COALESCE(u.name, '')`
|
||||
|
||||
func scanPlant(s scanner) (storage.Plant, error) {
|
||||
var plant storage.Plant
|
||||
err := s.Scan(
|
||||
&plant.ID, &plant.GardenID, &plant.SpeciesID, &plant.Name, &plant.Notes,
|
||||
&plant.AcquiredAt, &plant.Status, &plant.RemovedAt, &plant.Attributes, &plant.ImageData, &plant.ImageID,
|
||||
&plant.CreatedAt, &plant.UpdatedAt, &plant.Version, &plant.CreatedBy, &plant.UpdatedBy, &plant.PlantedBy, &plant.PlantedByName,
|
||||
)
|
||||
return plant, err
|
||||
}
|
||||
|
||||
// Insert creates a plant in its garden.
|
||||
func (m PlantModel) Insert(plant storage.Plant) (storage.Plant, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO plants
|
||||
(garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_data, image_id, created_by, updated_by, planted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10, $11, $12)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
plant.GardenID, plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt,
|
||||
plant.Status, plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.CreatedBy), nullableUserID(plant.UpdatedBy), nullableUserID(plant.PlantedBy),
|
||||
).Scan(&plant.ID, &plant.CreatedAt, &plant.UpdatedAt, &plant.Version)
|
||||
if err != nil {
|
||||
return storage.Plant{}, recordError(err)
|
||||
}
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
// Get returns a plant within its garden.
|
||||
func (m PlantModel) Get(gardenID, id int) (storage.Plant, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
plant, err := scanPlant(m.DB.QueryRowContext(ctx, `SELECT `+plantColumns+` FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 AND p.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.Plant{}, recordError(err)
|
||||
}
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists plants in a garden.
|
||||
func (m PlantModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+plantColumns+`
|
||||
FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 ORDER BY p.name, p.id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
plants := []storage.Plant{}
|
||||
for rows.Next() {
|
||||
plant, err := scanPlant(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plants = append(plants, plant)
|
||||
}
|
||||
return plants, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a plant using optimistic locking.
|
||||
func (m PlantModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE plants SET species_id = $1, name = $2, notes = $3, acquired_at = $4,
|
||||
status = $5, removed_at = $6, attributes = $7, image_data = '', image_id = $8, updated_by = $9,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id = $10 AND id = $11 AND version = $12
|
||||
RETURNING updated_at, version`,
|
||||
plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, plant.Status,
|
||||
plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.UpdatedBy), gardenID, plant.ID, plant.Version,
|
||||
).Scan(&plant.UpdatedAt, &plant.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Plant{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Plant{}, recordError(err)
|
||||
}
|
||||
return plant, nil
|
||||
}
|
||||
|
||||
// Delete removes a plant within its garden.
|
||||
func (m PlantModel) Delete(gardenID, id int) error {
|
||||
return deleteByGardenID(m.DB, `DELETE FROM plants WHERE garden_id = $1 AND id = $2`, gardenID, id)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// RoleModel implements storage.RoleModelInterface for shared and garden-owned
|
||||
// PostgreSQL roles.
|
||||
type RoleModel struct{ DB *sql.DB }
|
||||
|
||||
// List returns shared roles in a scope.
|
||||
func (m RoleModel) List(scope storage.RoleScope) ([]storage.Role, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope=$1 AND r.garden_id IS NULL GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, scope)
|
||||
return scanRoles(rows, err)
|
||||
}
|
||||
|
||||
// ListForGarden returns shared and custom roles available in a garden.
|
||||
func (m RoleModel) ListForGarden(gardenID int) ([]storage.Role, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope='garden' AND (r.garden_id IS NULL OR r.garden_id=$1) GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, gardenID)
|
||||
return scanRoles(rows, err)
|
||||
}
|
||||
|
||||
func scanRoles(rows *sql.Rows, err error) ([]storage.Role, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
roles := []storage.Role{}
|
||||
for rows.Next() {
|
||||
var role storage.Role
|
||||
var permissions pq.StringArray
|
||||
if err := rows.Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role.Permissions = []string(permissions)
|
||||
roles = append(roles, role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
// Get returns a shared role by name.
|
||||
func (m RoleModel) Get(name string) (storage.Role, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var role storage.Role
|
||||
var permissions pq.StringArray
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.name=$1 GROUP BY r.name`, name).Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions)
|
||||
if err != nil {
|
||||
return storage.Role{}, recordError(err)
|
||||
}
|
||||
role.Permissions = []string(permissions)
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// GetForGarden returns a shared or custom role available in a garden.
|
||||
func (m RoleModel) GetForGarden(gardenID int, name string) (storage.Role, error) {
|
||||
role, err := m.Get(name)
|
||||
if err != nil {
|
||||
return storage.Role{}, err
|
||||
}
|
||||
if role.Scope != storage.RoleScopeGarden || role.GardenID != nil && *role.GardenID != gardenID {
|
||||
return storage.Role{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Create adds a role and its base permissions atomically.
|
||||
func (m RoleModel) Create(role storage.Role) (storage.Role, error) {
|
||||
if role.Scope != storage.RoleScopeApplication && role.Scope != storage.RoleScopeGarden {
|
||||
return storage.Role{}, storage.ErrConflict
|
||||
}
|
||||
if role.Scope == storage.RoleScopeApplication && role.GardenID != nil {
|
||||
return storage.Role{}, storage.ErrConflict
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return role, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO roles(name,scope,garden_id,label) VALUES($1,$2,$3,$4)`, role.Name, role.Scope, role.GardenID, role.Label); err != nil {
|
||||
return role, recordError(err)
|
||||
}
|
||||
for _, p := range role.Permissions {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil {
|
||||
return role, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return role, err
|
||||
}
|
||||
return m.Get(role.Name)
|
||||
}
|
||||
|
||||
// Update replaces a role's label and base permissions atomically.
|
||||
func (m RoleModel) Update(role storage.Role) (storage.Role, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return role, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE roles SET label=$1,updated_at=CURRENT_TIMESTAMP WHERE name=$2`, role.Label, role.Name)
|
||||
if err != nil {
|
||||
return role, err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return role, countErr
|
||||
} else if count == 0 {
|
||||
return storage.Role{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_name=$1`, role.Name); err != nil {
|
||||
return role, err
|
||||
}
|
||||
for _, p := range role.Permissions {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil {
|
||||
return role, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return role, err
|
||||
}
|
||||
return m.Get(role.Name)
|
||||
}
|
||||
|
||||
// Delete removes a non-system shared role that is not assigned.
|
||||
func (m RoleModel) Delete(name string) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM roles WHERE name=$1 AND system=false`, name)
|
||||
if err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGardenOverrides lists explicit permission grants and revocations in a garden.
|
||||
func (m RoleModel) ListGardenOverrides(gardenID int) ([]storage.GardenRolePermissionOverride, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT garden_id,role_name,permission,granted FROM garden_role_permission_overrides WHERE garden_id=$1 ORDER BY role_name,permission`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.GardenRolePermissionOverride{}
|
||||
for rows.Next() {
|
||||
var o storage.GardenRolePermissionOverride
|
||||
if err := rows.Scan(&o.GardenID, &o.RoleName, &o.Permission, &o.Granted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, o)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceGardenOverrides atomically replaces all permission overrides for a
|
||||
// role in one garden.
|
||||
func (m RoleModel) ReplaceGardenOverrides(gardenID int, roleName string, overrides []storage.GardenRolePermissionOverride) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists bool
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, roleName, gardenID).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_role_permission_overrides WHERE garden_id=$1 AND role_name=$2`, gardenID, roleName); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range overrides {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO garden_role_permission_overrides(garden_id,role_name,permission,granted) VALUES($1,$2,$3,$4)`, gardenID, roleName, o.Permission, o.Granted); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var _ = sql.ErrNoRows
|
||||
@@ -0,0 +1,103 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestScopedRolesAndPersistedPermissions(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("150405.000000000")
|
||||
password := auth.NewPassword([]byte("integration-test-hash"))
|
||||
users := UserModel{DB: db}
|
||||
user, err := users.Insert(storage.User{Name: "Role integration", Email: "roles-" + stamp + "@example.com", Password: *password, Activated: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, user.ID) })
|
||||
if user.Role != storage.ApplicationRoleUser || !user.Can(storage.ApplicationPermissionGardensCreate) {
|
||||
t.Fatalf("new user role=%q permissions=%v", user.Role, user.Permissions)
|
||||
}
|
||||
|
||||
user, err = users.UpdateRole(user.ID, storage.ApplicationRoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !user.Can(storage.ApplicationPermissionUsersManage) || !user.Can(storage.ApplicationPermissionRolesManage) {
|
||||
t.Fatalf("admin permissions were not loaded: %v", user.Permissions)
|
||||
}
|
||||
|
||||
var gardenID int
|
||||
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Role integration "+stamp).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) })
|
||||
members := GardenMemberModel{DB: db}
|
||||
member, err := members.Insert(storage.GardenMember{GardenID: gardenID, UserID: user.ID, Role: storage.GardenRoleOwner})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !member.Can(storage.GardenPermissionGardenDelete) {
|
||||
t.Fatal("owner wildcard permission was not expanded")
|
||||
}
|
||||
if err = (RoleModel{DB: db}).ReplaceGardenOverrides(gardenID, string(storage.GardenRoleOwner), []storage.GardenRolePermissionOverride{{GardenID: gardenID, RoleName: string(storage.GardenRoleOwner), Permission: string(storage.GardenPermissionGardenDelete), Granted: false}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err = members.Get(gardenID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if member.Can(storage.GardenPermissionGardenDelete) || !member.Can(storage.GardenPermissionGardenUpdate) {
|
||||
t.Fatalf("garden override was not applied: %v", member.Permissions)
|
||||
}
|
||||
|
||||
var otherGardenID int
|
||||
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Other role integration "+stamp).Scan(&otherGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, otherGardenID) })
|
||||
roleName := "garden:" + strconv.Itoa(gardenID) + ":integration"
|
||||
role, err := (RoleModel{DB: db}).Create(storage.Role{
|
||||
Name: roleName, Scope: storage.RoleScopeGarden, GardenID: &gardenID,
|
||||
Label: "Integration", Permissions: []string{string(storage.GardenPermissionGardenRead)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role.GardenID == nil || *role.GardenID != gardenID {
|
||||
t.Fatalf("garden-specific role has garden_id=%v", role.GardenID)
|
||||
}
|
||||
roles, err := (RoleModel{DB: db}).ListForGarden(gardenID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range roles {
|
||||
found = found || candidate.Name == roleName
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("garden-specific role %q is missing", roleName)
|
||||
}
|
||||
if _, err = (RoleModel{DB: db}).GetForGarden(otherGardenID, roleName); err != storage.ErrRecordNotFound {
|
||||
t.Fatalf("role leaked into another garden: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// SpeciesModel stores global and garden-specific species data in PostgreSQL.
|
||||
// SpeciesModel implements storage.SpeciesModelInterface for global and
|
||||
// garden-owned species records.
|
||||
type SpeciesModel struct{ DB *sql.DB }
|
||||
|
||||
const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name,
|
||||
s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction,
|
||||
s.winter_protection, s.spacing_cm, s.height_cm,
|
||||
s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to,
|
||||
s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
|
||||
s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
|
||||
s.notes, s.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), s.image_id, s.created_at, s.updated_at, s.version,
|
||||
COALESCE(s.created_by, 0), COALESCE(s.updated_by, 0)`
|
||||
|
||||
func scanSpecies(s scanner) (storage.Species, error) {
|
||||
var species storage.Species
|
||||
err := s.Scan(
|
||||
&species.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
|
||||
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
|
||||
&species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
|
||||
&species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
|
||||
&species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom,
|
||||
&species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
|
||||
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
|
||||
)
|
||||
return species, err
|
||||
}
|
||||
|
||||
func speciesArgs(species storage.Species) []any {
|
||||
return []any{
|
||||
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
|
||||
species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
|
||||
species.WinterProtection, species.SpacingCM, species.HeightCM,
|
||||
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
|
||||
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
|
||||
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
|
||||
species.HarvestDayTo, species.Notes, jsonValue(species.Attributes), species.ImageID, nullableUserID(species.CreatedBy), nullableUserID(species.UpdatedBy),
|
||||
}
|
||||
}
|
||||
|
||||
// Insert creates a global or garden-owned species record.
|
||||
func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
query := `
|
||||
INSERT INTO species (
|
||||
garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
|
||||
soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm,
|
||||
sow_month_from, sow_day_from, sow_month_to, sow_day_to,
|
||||
planting_month_from, planting_day_from, planting_month_to, planting_day_to,
|
||||
harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
|
||||
notes, attributes, image_data, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28)
|
||||
RETURNING id, created_at, updated_at, version`
|
||||
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
|
||||
&species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return storage.Species{}, recordError(err)
|
||||
}
|
||||
return species, nil
|
||||
}
|
||||
|
||||
// Get returns a global or garden-owned species visible in gardenID.
|
||||
func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
species, err := scanSpecies(m.DB.QueryRowContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.id = $1 AND (s.garden_id IS NULL OR s.garden_id = $2)`, id, gardenID))
|
||||
if err != nil {
|
||||
return storage.Species{}, recordError(err)
|
||||
}
|
||||
return species, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists global species and species owned by a garden.
|
||||
func (m SpeciesModel) GetAllForGarden(gardenID int) ([]storage.Species, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesColumns+`
|
||||
FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id
|
||||
WHERE s.garden_id IS NULL OR s.garden_id = $1
|
||||
ORDER BY s.common_name, s.cultivar, s.id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
allSpecies := []storage.Species{}
|
||||
for rows.Next() {
|
||||
species, err := scanSpecies(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allSpecies = append(allSpecies, species)
|
||||
}
|
||||
return allSpecies, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a visible species using optimistic locking.
|
||||
func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Species, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
args := speciesArgs(species)
|
||||
// created_by is immutable and is intentionally not part of the UPDATE.
|
||||
// Remove it from the shared insert argument list so PostgreSQL does not
|
||||
// receive an unused, untyped parameter between image_id and updated_by.
|
||||
args = append(args[:26], args[27])
|
||||
args = append(args, gardenID, species.ID, species.Version)
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
|
||||
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
|
||||
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
|
||||
height_cm = $11, sow_month_from = $12, sow_day_from = $13,
|
||||
sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
|
||||
planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
|
||||
harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
|
||||
notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30
|
||||
RETURNING updated_at, version`, args...,
|
||||
).Scan(&species.UpdatedAt, &species.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Species{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Species{}, recordError(err)
|
||||
}
|
||||
return species, nil
|
||||
}
|
||||
|
||||
// Delete removes a species owned by the supplied garden.
|
||||
func (m SpeciesModel) Delete(gardenID, id int) error {
|
||||
return deleteByGardenID(m.DB, `DELETE FROM species WHERE garden_id IS NOT DISTINCT FROM NULLIF($1, 0) AND id = $2`, gardenID, id)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// SpeciesCategoryModel stores globally configured species categories.
|
||||
// SpeciesCategoryModel implements storage.SpeciesCategoryModelInterface for
|
||||
// the application-wide category catalogue.
|
||||
type SpeciesCategoryModel struct{ DB *sql.DB }
|
||||
|
||||
func scanSpeciesCategory(s scanner) (storage.SpeciesCategory, error) {
|
||||
var category storage.SpeciesCategory
|
||||
err := s.Scan(&category.ID, &category.Name, &category.SortOrder, &category.Active, &category.CreatedAt, &category.UpdatedAt, &category.Version, &category.Lifecycle)
|
||||
return category, err
|
||||
}
|
||||
|
||||
// Insert creates a species category.
|
||||
func (m SpeciesCategoryModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO species_categories (name, sort_order, active, lifecycle)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at, updated_at, version`, category.Name, category.SortOrder, category.Active,
|
||||
category.Lifecycle).Scan(&category.ID, &category.CreatedAt, &category.UpdatedAt, &category.Version)
|
||||
if err != nil {
|
||||
return storage.SpeciesCategory{}, recordError(err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// Get returns a species category by ID.
|
||||
func (m SpeciesCategoryModel) Get(id int) (storage.SpeciesCategory, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
category, err := scanSpeciesCategory(m.DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle
|
||||
FROM species_categories WHERE id = $1`, id))
|
||||
if err != nil {
|
||||
return storage.SpeciesCategory{}, recordError(err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// GetAll lists all species categories in display order.
|
||||
func (m SpeciesCategoryModel) GetAll() ([]storage.SpeciesCategory, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle
|
||||
FROM species_categories ORDER BY sort_order, lower(name), id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
categories := []storage.SpeciesCategory{}
|
||||
for rows.Next() {
|
||||
category, scanErr := scanSpeciesCategory(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
categories = append(categories, category)
|
||||
}
|
||||
return categories, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a species category using optimistic locking.
|
||||
func (m SpeciesCategoryModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE species_categories
|
||||
SET name = $1, sort_order = $2, active = $3, lifecycle = $4, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $5 AND version = $6
|
||||
RETURNING updated_at, version`, category.Name, category.SortOrder, category.Active, category.Lifecycle, category.ID, category.Version).Scan(&category.UpdatedAt, &category.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.SpeciesCategory{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.SpeciesCategory{}, recordError(err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeciesModelListsSeededGlobalSpecies(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
species, err := (SpeciesModel{DB: db}).GetAllForGarden(2_147_483_647)
|
||||
if err != nil {
|
||||
t.Fatalf("list global species: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range species {
|
||||
if item.GardenID == nil && item.CommonName == "Tomate" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("seeded global species Tomate was not returned")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// SpeciesTaskTemplateModel stores recurring species task rules in PostgreSQL.
|
||||
// SpeciesTaskTemplateModel implements storage.SpeciesTaskTemplateModelInterface
|
||||
// and resolves global species through the requesting garden boundary.
|
||||
type SpeciesTaskTemplateModel struct{ DB *sql.DB }
|
||||
|
||||
const speciesTaskTemplateColumns = `t.id, t.species_id, t.origin, t.title, t.description, t.trigger_type,
|
||||
t.month_from, t.day_from, t.month_to, t.day_to, t.offset_days_from, t.offset_days_to,
|
||||
t.interval_days, t.trigger_offset, t.trigger_offset_unit, t.duration, t.duration_unit,
|
||||
t.recurrence, t.recurrence_interval, t.priority, t.active, t.created_at, t.updated_at, t.version`
|
||||
|
||||
func scanSpeciesTaskTemplate(s scanner) (storage.SpeciesTaskTemplate, error) {
|
||||
var template storage.SpeciesTaskTemplate
|
||||
err := s.Scan(
|
||||
&template.ID, &template.SpeciesID, &template.Origin, &template.Title, &template.Description,
|
||||
&template.TriggerType, &template.MonthFrom, &template.DayFrom, &template.MonthTo,
|
||||
&template.DayTo, &template.OffsetDaysFrom, &template.OffsetDaysTo,
|
||||
&template.IntervalDays, &template.TriggerOffset, &template.TriggerOffsetUnit, &template.Duration, &template.DurationUnit,
|
||||
&template.Recurrence, &template.RecurrenceInterval, &template.Priority, &template.Active, &template.CreatedAt,
|
||||
&template.UpdatedAt, &template.Version,
|
||||
)
|
||||
return template, err
|
||||
}
|
||||
|
||||
// Insert creates a task template for a species.
|
||||
func (m SpeciesTaskTemplateModel) Insert(template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO species_task_templates (
|
||||
species_id, origin, title, description, trigger_type, month_from, day_from,
|
||||
month_to, day_to, offset_days_from, offset_days_to, interval_days,
|
||||
trigger_offset, trigger_offset_unit, duration, duration_unit, recurrence, recurrence_interval, priority, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType,
|
||||
template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo,
|
||||
template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays,
|
||||
template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit,
|
||||
template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active,
|
||||
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt, &template.Version)
|
||||
if err != nil {
|
||||
return storage.SpeciesTaskTemplate{}, err
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// Get returns a task template visible in a garden.
|
||||
func (m SpeciesTaskTemplateModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
template, err := scanSpeciesTaskTemplate(m.DB.QueryRowContext(ctx,
|
||||
`SELECT `+speciesTaskTemplateColumns+` FROM species_task_templates t JOIN species s ON s.id = t.species_id WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.SpeciesTaskTemplate{}, recordError(err)
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// GetAllForSpecies lists task templates for a species visible in a garden.
|
||||
func (m SpeciesTaskTemplateModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesTaskTemplateColumns+`
|
||||
FROM species_task_templates t JOIN species s ON s.id = t.species_id
|
||||
WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.species_id = $2
|
||||
ORDER BY t.active DESC, t.priority DESC, t.title, t.id`, gardenID, speciesID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
templates := []storage.SpeciesTaskTemplate{}
|
||||
for rows.Next() {
|
||||
template, err := scanSpeciesTaskTemplate(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates = append(templates, template)
|
||||
}
|
||||
return templates, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a task template using optimistic locking.
|
||||
func (m SpeciesTaskTemplateModel) Update(gardenID int, template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE species_task_templates SET species_id = $1, origin = $2, title = $3, description = $4,
|
||||
trigger_type = $5, month_from = $6, day_from = $7, month_to = $8,
|
||||
day_to = $9, offset_days_from = $10, offset_days_to = $11,
|
||||
interval_days = $12, trigger_offset = $13, trigger_offset_unit = $14,
|
||||
duration = $15, duration_unit = $16, recurrence = $17, recurrence_interval = $18,
|
||||
priority = $19, active = $20,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $21 AND version = $22
|
||||
AND EXISTS (SELECT 1 FROM species s WHERE s.id = species_task_templates.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($23, 0))
|
||||
RETURNING updated_at, version`,
|
||||
template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType,
|
||||
template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo,
|
||||
template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays,
|
||||
template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit,
|
||||
template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, template.ID, template.Version, gardenID,
|
||||
).Scan(&template.UpdatedAt, &template.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.SpeciesTaskTemplate{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.SpeciesTaskTemplate{}, err
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// Delete removes a task template visible in a garden.
|
||||
func (m SpeciesTaskTemplateModel) Delete(gardenID, id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM species_task_templates t USING species s WHERE t.id = $1 AND s.id = t.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($2, 0)`, id, gardenID)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// TagModel manages global and garden-local tags for supported entity types.
|
||||
type TagModel struct{ DB *sql.DB }
|
||||
|
||||
// GetAllForGarden lists global and garden-local tag names.
|
||||
func (m TagModel) GetAllForGarden(gardenID int) ([]string, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT DISTINCT name FROM tags WHERE garden_id=$1 ORDER BY name`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func tagRelation(entity storage.TagEntity) (string, string, error) {
|
||||
switch entity {
|
||||
case storage.TagEntityTask:
|
||||
return "task_tags", "task_id", nil
|
||||
case storage.TagEntityPlant:
|
||||
return "plant_tags", "plant_id", nil
|
||||
case storage.TagEntitySpecies:
|
||||
return "species_tags", "species_id", nil
|
||||
case storage.TagEntityJournal:
|
||||
return "journal_entry_tags", "journal_entry_id", nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unknown tag entity %q", entity)
|
||||
}
|
||||
}
|
||||
|
||||
// Get lists tags assigned to an entity within its garden.
|
||||
func (m TagModel) Get(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) {
|
||||
table, column, err := tagRelation(entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT t.name FROM tags t JOIN `+table+` et ON et.tag_id=t.id WHERE t.garden_id IS NOT DISTINCT FROM NULLIF($1,0) AND et.`+column+`=$2 ORDER BY t.name`, gardenID, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Set atomically replaces an entity's tags after verifying that the entity
|
||||
// belongs to the supplied garden.
|
||||
func (m TagModel) Set(gardenID int, entity storage.TagEntity, entityID int, tags []string) ([]string, error) {
|
||||
table, column, err := tagRelation(entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = storage.NormalizeTags(tags)
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE `+column+`=$1`, entityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tag := range tags {
|
||||
var tagID int
|
||||
if gardenID == 0 {
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES(NULL,$1) ON CONFLICT(name) WHERE garden_id IS NULL DO UPDATE SET name=EXCLUDED.name RETURNING id`, tag).Scan(&tagID)
|
||||
} else {
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES($1,$2) ON CONFLICT(garden_id,name) DO UPDATE SET name=EXCLUDED.name RETURNING id`, gardenID, tag).Scan(&tagID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO `+table+`(`+column+`,tag_id) VALUES($1,$2)`, entityID, tagID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestGeneratedTaskInsertIsIdempotentUnderConcurrency(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var userID, gardenID, speciesID, plantID, templateID int
|
||||
if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Generator integration', $1, 'hash', true) RETURNING id`, "generator-"+time.Now().Format("150405.000000000")+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Generator integration') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
if err := db.QueryRow(`INSERT INTO species (garden_id, common_name) VALUES ($1, 'Parallelrose') RETURNING id`, gardenID).Scan(&speciesID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO plants (garden_id, species_id, name) VALUES ($1, $2, 'Rose') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, month_to) VALUES ($1, 'Schneiden', 'month_of_year', 2, 3) RETURNING id`, speciesID).Scan(&templateID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
generatedFor := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
dueStart, dueEnd := generatedFor, time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC)
|
||||
model := TaskModel{DB: db}
|
||||
const workers = 12
|
||||
results := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := model.Insert(storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: "Schneiden", DueAtStart: &dueStart, DueAtEnd: &dueEnd, GeneratedFor: &generatedFor, CreatedBy: userID})
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
successes, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successes++
|
||||
} else if errors.Is(err, storage.ErrConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatalf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != workers-1 {
|
||||
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE plant_id = $1 AND template_id = $2 AND generated_for = $3`, plantID, templateID, generatedFor).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// TaskPriorityModel implements storage.TaskPriorityModelInterface for the
|
||||
// application-wide priority catalogue.
|
||||
type TaskPriorityModel struct{ DB *sql.DB }
|
||||
|
||||
func scanTaskPriority(s scanner) (storage.TaskPriority, error) {
|
||||
var priority storage.TaskPriority
|
||||
err := s.Scan(&priority.ID, &priority.Name, &priority.Value, &priority.SortOrder, &priority.Active, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version)
|
||||
return priority, err
|
||||
}
|
||||
|
||||
// Insert creates a priority catalogue entry.
|
||||
func (m TaskPriorityModel) Insert(priority storage.TaskPriority) (storage.TaskPriority, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `INSERT INTO task_priorities (name, value, sort_order, active) VALUES ($1,$2,$3,$4) RETURNING id, created_at, updated_at, version`, priority.Name, priority.Value, priority.SortOrder, priority.Active).Scan(&priority.ID, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version)
|
||||
if err != nil {
|
||||
return storage.TaskPriority{}, recordError(err)
|
||||
}
|
||||
return priority, nil
|
||||
}
|
||||
|
||||
// Get returns a priority catalogue entry by ID.
|
||||
func (m TaskPriorityModel) Get(id int) (storage.TaskPriority, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
priority, err := scanTaskPriority(m.DB.QueryRowContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
return storage.TaskPriority{}, recordError(err)
|
||||
}
|
||||
return priority, nil
|
||||
}
|
||||
|
||||
// GetAll lists all priority catalogue entries in display order.
|
||||
func (m TaskPriorityModel) GetAll() ([]storage.TaskPriority, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities ORDER BY sort_order, value, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.TaskPriority{}
|
||||
for rows.Next() {
|
||||
value, err := scanTaskPriority(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a priority catalogue entry using optimistic locking.
|
||||
func (m TaskPriorityModel) Update(priority storage.TaskPriority) (storage.TaskPriority, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `UPDATE task_priorities SET name=$1,value=$2,sort_order=$3,active=$4,updated_at=CURRENT_TIMESTAMP,version=version+1 WHERE id=$5 AND version=$6 RETURNING updated_at,version`, priority.Name, priority.Value, priority.SortOrder, priority.Active, priority.ID, priority.Version).Scan(&priority.UpdatedAt, &priority.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.TaskPriority{}, storage.ErrEditConflict
|
||||
}
|
||||
if err != nil {
|
||||
return storage.TaskPriority{}, recordError(err)
|
||||
}
|
||||
return priority, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// TaskTemplateOptOutModel stores per-plant task-generation suppression while
|
||||
// enforcing the garden boundary.
|
||||
type TaskTemplateOptOutModel struct{ DB *sql.DB }
|
||||
|
||||
// IsOptedOut reports whether task generation is suppressed for a plant-template pair.
|
||||
func (m TaskTemplateOptOutModel) IsOptedOut(gardenID, plantID, templateID int) (bool, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var value bool
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id JOIN species_task_templates st ON st.id=o.template_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3 AND st.species_id=p.species_id)`, gardenID, plantID, templateID).Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
// GetAllForPlant lists suppressed template IDs for a plant.
|
||||
func (m TaskTemplateOptOutModel) GetAllForPlant(gardenID, plantID int) ([]int, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT o.template_id FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id WHERE p.garden_id=$1 AND p.id=$2 ORDER BY o.template_id`, gardenID, plantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []int{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Set creates or removes suppression for a plant-template pair.
|
||||
func (m TaskTemplateOptOutModel) Set(gardenID, plantID, templateID int, optedOut bool) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
var valid bool
|
||||
err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM plants p JOIN species_task_templates st ON st.species_id=p.species_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3)`, gardenID, plantID, templateID).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
if optedOut {
|
||||
_, err = m.DB.ExecContext(ctx, `INSERT INTO task_template_opt_outs(plant_id,template_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, plantID, templateID)
|
||||
} else {
|
||||
_, err = m.DB.ExecContext(ctx, `DELETE FROM task_template_opt_outs WHERE plant_id=$1 AND template_id=$2`, plantID, templateID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// TaskModel stores garden tasks in PostgreSQL.
|
||||
// TaskModel implements storage.TaskModelInterface for garden-scoped work items.
|
||||
type TaskModel struct{ DB *sql.DB }
|
||||
|
||||
const taskColumns = `id, garden_id, plant_id, location_id, template_id, title,
|
||||
description, due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by,
|
||||
priority, active, created_by, created_at, updated_at, version, plant_status_on_completion`
|
||||
|
||||
func scanTask(s scanner) (storage.Task, error) {
|
||||
var task storage.Task
|
||||
err := s.Scan(
|
||||
&task.ID, &task.GardenID, &task.PlantID, &task.LocationID, &task.TemplateID,
|
||||
&task.Title, &task.Description, &task.DueAtStart, &task.DueAtEnd,
|
||||
&task.GeneratedFor, &task.Recurrence, &task.RecurrenceInterval, &task.RepeatFromID, &task.CompletedAt, &task.CompletedBy, &task.Priority, &task.Active,
|
||||
&task.CreatedBy, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.PlantStatusOnCompletion,
|
||||
)
|
||||
return task, err
|
||||
}
|
||||
|
||||
// Insert creates a task; generated task slots remain idempotent under concurrency.
|
||||
func (m TaskModel) Insert(task storage.Task) (storage.Task, error) {
|
||||
if task.RecurrenceInterval < 1 {
|
||||
task.RecurrenceInterval = 1
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO tasks (
|
||||
garden_id, plant_id, location_id, template_id, title, description,
|
||||
due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by,
|
||||
priority, active, created_by, plant_status_on_completion)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id, created_at, updated_at, version`,
|
||||
task.GardenID, task.PlantID, task.LocationID, task.TemplateID, task.Title,
|
||||
task.Description, task.DueAtStart, task.DueAtEnd, task.GeneratedFor,
|
||||
task.Recurrence, task.RecurrenceInterval, task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.CreatedBy, task.PlantStatusOnCompletion,
|
||||
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt, &task.Version)
|
||||
if err != nil {
|
||||
return storage.Task{}, recordError(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// Get returns a task within its garden.
|
||||
func (m TaskModel) Get(gardenID, id int) (storage.Task, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
task, err := scanTask(m.DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id))
|
||||
if err != nil {
|
||||
return storage.Task{}, recordError(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists tasks in a garden.
|
||||
func (m TaskModel) GetAllForGarden(gardenID int) ([]storage.Task, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT `+taskColumns+`
|
||||
FROM tasks WHERE garden_id = $1
|
||||
ORDER BY completed_at NULLS FIRST, due_at_end NULLS LAST, priority DESC, id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := []storage.Task{}
|
||||
for rows.Next() {
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a task using optimistic locking.
|
||||
func (m TaskModel) Update(gardenID int, task storage.Task) (storage.Task, error) {
|
||||
if task.RecurrenceInterval < 1 {
|
||||
task.RecurrenceInterval = 1
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE tasks SET plant_id = $1, location_id = $2, template_id = $3,
|
||||
title = $4, description = $5, due_at_start = $6, due_at_end = $7,
|
||||
generated_for = $8, recurrence = $9, recurrence_interval = $10, repeat_from_id = $11, completed_at = $12, completed_by = $13,
|
||||
priority = $14, active = $15, plant_status_on_completion=$16, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id = $17 AND id = $18 AND version = $19
|
||||
RETURNING updated_at, version`,
|
||||
task.PlantID, task.LocationID, task.TemplateID, task.Title, task.Description,
|
||||
task.DueAtStart, task.DueAtEnd, task.GeneratedFor, task.Recurrence, task.RecurrenceInterval,
|
||||
task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.PlantStatusOnCompletion, gardenID, task.ID, task.Version,
|
||||
).Scan(&task.UpdatedAt, &task.Version)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return storage.Task{}, storage.ErrEditConflict
|
||||
}
|
||||
return storage.Task{}, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// Delete removes a task within its garden.
|
||||
func (m TaskModel) Delete(gardenID, id int) error {
|
||||
return deleteByID(m.DB, `DELETE FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestTaskModelEnforcesGardenBoundary(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("connect to PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
var userID, gardenID, foreignGardenID int
|
||||
if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Task integration', $1, 'hash', true) RETURNING id`, "task-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration A') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration B') RETURNING id`).Scan(&foreignGardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
model := TaskModel{DB: db}
|
||||
local, err := model.Insert(storage.Task{GardenID: gardenID, Title: "Gießen", CreatedBy: userID})
|
||||
if err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
foreign, err := model.Insert(storage.Task{GardenID: foreignGardenID, Title: "Fremd", CreatedBy: userID})
|
||||
if err != nil {
|
||||
t.Fatalf("insert foreign task: %v", err)
|
||||
}
|
||||
if _, err := model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign lookup: got %v", err)
|
||||
}
|
||||
local.Title = "Kräftig gießen"
|
||||
updated, err := model.Update(gardenID, local)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("update: task=%+v err=%v", updated, err)
|
||||
}
|
||||
if err := model.Delete(foreignGardenID, local.ID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("foreign delete: got %v", err)
|
||||
}
|
||||
if listed, err := model.GetAllForGarden(gardenID); err != nil || len(listed) != 1 || listed[0].ID != local.ID {
|
||||
t.Fatalf("list: tasks=%+v err=%v", listed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
)
|
||||
|
||||
// TokenModel stores scoped authentication token hashes in PostgreSQL.
|
||||
// TokenModel persists only token hashes; plaintext tokens are returned once to
|
||||
// the caller and never stored.
|
||||
type TokenModel struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// New creates a token and persists only its hash.
|
||||
func (m TokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) {
|
||||
token := auth.NewToken(userID, ttl, scope)
|
||||
|
||||
err := m.insert(token)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (m TokenModel) insert(token auth.Token) error {
|
||||
query := `
|
||||
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||
VALUES ($1, $2, $3, $4)`
|
||||
|
||||
args := []any{token.Hash, token.UserID, token.Expiry, token.Scope}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.DB.ExecContext(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllForUser revokes every token for one user and scope.
|
||||
func (m TokenModel) DeleteAllForUser(scope string, userID int) error {
|
||||
query := `
|
||||
DELETE FROM tokens
|
||||
WHERE scope = $1 AND user_id = $2`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.DB.ExecContext(ctx, query, scope, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// UserModel stores user accounts in PostgreSQL.
|
||||
// UserModel implements storage.UserModelInterface for PostgreSQL accounts.
|
||||
type UserModel struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Insert creates a user account.
|
||||
func (m UserModel) Insert(user storage.User) (storage.User, error) {
|
||||
query := `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role)
|
||||
VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM users WHERE deleted_at IS NULL) THEN $5 ELSE $6 END)
|
||||
RETURNING id, created_at, color, application_role, version`
|
||||
|
||||
args := []any{user.Name, user.Email, user.Password.Get(), user.Activated, storage.ApplicationRoleUser, storage.ApplicationRoleAdmin}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Serialize registrations until a first administrator exists. Without this,
|
||||
// two simultaneous registrations could both observe an empty users table.
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(71432026)`); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Color, &user.Role, &user.Version)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
switch {
|
||||
case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key":
|
||||
return storage.User{}, storage.ErrDuplicateEmail
|
||||
default:
|
||||
return storage.User{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
if err = m.loadPermissions(&user); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByID returns a non-deleted account with resolved application permissions.
|
||||
func (m UserModel) GetByID(id int) (storage.User, error) {
|
||||
query := `
|
||||
SELECT id, created_at, updated_at, name, email, password_hash, activated, color, application_role, version
|
||||
FROM users
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
var user storage.User
|
||||
var pwHash []byte
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := m.DB.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.Name,
|
||||
&user.Email,
|
||||
&pwHash,
|
||||
&user.Activated,
|
||||
&user.Color,
|
||||
&user.Role,
|
||||
&user.Version,
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
default:
|
||||
return storage.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
user.Password = *auth.NewPassword(pwHash)
|
||||
if err = m.loadPermissions(&user); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail returns a non-deleted account by case-insensitive email.
|
||||
func (m UserModel) GetByEmail(email string) (storage.User, error) {
|
||||
query := `
|
||||
SELECT id, created_at, name, email, password_hash, activated, color, application_role, version
|
||||
FROM users
|
||||
WHERE email = $1 AND deleted_at IS NULL`
|
||||
|
||||
var user storage.User
|
||||
var pwHash []byte
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := m.DB.QueryRowContext(ctx, query, email).Scan(
|
||||
&user.ID,
|
||||
&user.CreatedAt,
|
||||
&user.Name,
|
||||
&user.Email,
|
||||
&pwHash,
|
||||
&user.Activated,
|
||||
&user.Color,
|
||||
&user.Role,
|
||||
&user.Version,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
default:
|
||||
return storage.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
user.Password = *auth.NewPassword(pwHash)
|
||||
if err = m.loadPermissions(&user); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Update changes an account using optimistic locking.
|
||||
func (m UserModel) Update(user storage.User) (storage.User, error) {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET name = $1, email = $2, password_hash = $3, activated = $4, color = $5,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE id = $6 AND version = $7 AND deleted_at IS NULL
|
||||
RETURNING updated_at, version`
|
||||
|
||||
args := []any{
|
||||
user.Name,
|
||||
user.Email,
|
||||
user.Password.Get(),
|
||||
user.Activated,
|
||||
user.Color,
|
||||
user.ID,
|
||||
user.Version,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.UpdatedAt, &user.Version)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
switch {
|
||||
case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key":
|
||||
return storage.User{}, storage.ErrDuplicateEmail
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.User{}, storage.ErrEditConflict
|
||||
default:
|
||||
return storage.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetForToken resolves a non-expired hashed token to its user.
|
||||
func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (storage.User, error) {
|
||||
tokenHash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
|
||||
query := `
|
||||
SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.color, users.application_role, users.version
|
||||
FROM users
|
||||
INNER JOIN tokens
|
||||
ON users.id = tokens.user_id
|
||||
WHERE tokens.hash = $1
|
||||
AND tokens.scope = $2
|
||||
AND tokens.expiry > $3
|
||||
AND users.deleted_at IS NULL`
|
||||
|
||||
args := []any{tokenHash[:], tokenScope, time.Now()}
|
||||
|
||||
var user storage.User
|
||||
var pwHash []byte
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := m.DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&user.ID,
|
||||
&user.CreatedAt,
|
||||
&user.Name,
|
||||
&user.Email,
|
||||
&pwHash,
|
||||
&user.Activated,
|
||||
&user.Color,
|
||||
&user.Role,
|
||||
&user.Version,
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
default:
|
||||
return storage.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
user.Password = *auth.NewPassword(pwHash)
|
||||
if err = m.loadPermissions(&user); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetAll lists non-deleted accounts with resolved application permissions.
|
||||
func (m UserModel) GetAll() ([]storage.User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id, created_at, updated_at, name, email, activated, color, application_role, version FROM users WHERE deleted_at IS NULL ORDER BY name, email, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := []storage.User{}
|
||||
for rows.Next() {
|
||||
var user storage.User
|
||||
if err := rows.Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt, &user.Name, &user.Email, &user.Activated, &user.Color, &user.Role, &user.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if err = m.loadPermissions(&users[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateRole changes an account's application role.
|
||||
func (m UserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `UPDATE users SET application_role=$1, updated_at=CURRENT_TIMESTAMP, version=version+1 WHERE id=$2 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='application')`, role, userID)
|
||||
if err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.User{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return m.GetByID(userID)
|
||||
}
|
||||
|
||||
// Delete removes access and personal account data while retaining the user row
|
||||
// as an anonymized author for historical garden content.
|
||||
// Delete anonymizes an account while preserving authored garden content and
|
||||
// removes authentication material and memberships transactionally.
|
||||
func (m UserModel) Delete(userID int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var email string
|
||||
err = tx.QueryRowContext(ctx, `SELECT email FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&email)
|
||||
if err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT garden_id FROM garden_members WHERE user_id=$1 ORDER BY garden_id`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gardenIDs := []int{}
|
||||
for rows.Next() {
|
||||
var gardenID int
|
||||
if err = rows.Scan(&gardenID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
gardenIDs = append(gardenIDs, gardenID)
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, gardenID := range gardenIDs {
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var ownsGarden bool
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM garden_members WHERE user_id=$1 AND role='owner')`, userID).Scan(&ownsGarden); err != nil {
|
||||
return err
|
||||
}
|
||||
if ownsGarden {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_invites WHERE invited_by=$1 OR email=$2`, userID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE users SET name='Gelöschter Nutzer', email='deleted-' || id::text || '@invalid',
|
||||
activated=false, application_role=$2, deleted_at=now(), updated_at=now(), version=version+1
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, storage.ApplicationRoleUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return countErr
|
||||
} else if count == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (m UserModel) loadPermissions(user *storage.User) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT permission FROM role_permissions WHERE role_name=$1 ORDER BY permission`, user.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
user.Permissions = []storage.ApplicationPermission{}
|
||||
for rows.Next() {
|
||||
var permission storage.ApplicationPermission
|
||||
if err := rows.Scan(&permission); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Permissions = append(user.Permissions, permission)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// CreateEmailChange stores a pending address and returns its one-time plaintext token.
|
||||
func (m UserModel) CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) {
|
||||
plaintext := rand.Text()
|
||||
hash := sha256.Sum256([]byte(plaintext))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err := m.DB.ExecContext(ctx, `INSERT INTO user_email_changes (user_id,email,token_hash,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id) DO UPDATE SET email=EXCLUDED.email,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,created_at=now()`, userID, email, hash[:], time.Now().Add(ttl))
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
return "", storage.ErrDuplicateEmail
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// ConfirmEmailChange consumes a token and applies its pending address atomically.
|
||||
func (m UserModel) ConfirmEmailChange(tokenPlaintext string, expectedUserID int) (storage.User, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID int
|
||||
var email string
|
||||
err = tx.QueryRowContext(ctx, `SELECT user_id,email FROM user_email_changes WHERE token_hash=$1 AND user_id=$2 AND expires_at>now() FOR UPDATE`, hash[:], expectedUserID).Scan(&userID, &email)
|
||||
if err != nil {
|
||||
return storage.User{}, recordError(err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE users SET email=$1,updated_at=now(),version=version+1 WHERE id=$2 AND deleted_at IS NULL`, email, userID)
|
||||
if err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.User{}, err
|
||||
}
|
||||
return m.GetByID(userID)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestDeleteUserAnonymizesAccountAndPreservesAuthoredContent(t *testing.T) {
|
||||
dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err = db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")
|
||||
originalEmail := "delete-" + stamp + "@example.com"
|
||||
var ownerID, targetID, gardenID, entryID int
|
||||
if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Owner',$1,'hash',true) RETURNING id`, "owner-delete-"+stamp+"@example.com").Scan(&ownerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Personal Name',$1,'hash',true) RETURNING id`, originalEmail).Scan(&targetID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Deletion integration') RETURNING id`).Scan(&gardenID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID)
|
||||
_, _ = db.Exec(`DELETE FROM users WHERE id IN ($1,$2)`, ownerID, targetID)
|
||||
})
|
||||
if _, err = db.Exec(`INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,'owner'),($1,$3,'member')`, gardenID, ownerID, targetID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`INSERT INTO journal_entries (garden_id,author_id,title) VALUES ($1,$2,'Bleibt erhalten') RETURNING id`, gardenID, targetID).Scan(&entryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
users := UserModel{DB: db}
|
||||
if err = users.Delete(targetID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = users.GetByEmail(originalEmail); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("deleted email still resolves: %v", err)
|
||||
}
|
||||
if _, err = users.GetByID(targetID); !errors.Is(err, storage.ErrRecordNotFound) {
|
||||
t.Fatalf("deleted user ID still resolves: %v", err)
|
||||
}
|
||||
var name, email string
|
||||
var activated bool
|
||||
var deletedAt time.Time
|
||||
if err = db.QueryRow(`SELECT name,email,activated,deleted_at FROM users WHERE id=$1`, targetID).Scan(&name, &email, &activated, &deletedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "Gelöschter Nutzer" || email == originalEmail || activated || deletedAt.IsZero() {
|
||||
t.Fatalf("account was not anonymized: name=%q email=%q activated=%t deleted_at=%v", name, email, activated, deletedAt)
|
||||
}
|
||||
var membershipCount, entryCount int
|
||||
if err = db.QueryRow(`SELECT count(*) FROM garden_members WHERE user_id=$1`, targetID).Scan(&membershipCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRow(`SELECT count(*) FROM journal_entries WHERE id=$1 AND author_id=$2`, entryID, targetID).Scan(&entryCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if membershipCount != 0 || entryCount != 1 {
|
||||
t.Fatalf("membership=%d preserved entries=%d", membershipCount, entryCount)
|
||||
}
|
||||
var replacementID int
|
||||
if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Replacement',$1,'hash',true) RETURNING id`, originalEmail).Scan(&replacementID); err != nil {
|
||||
t.Fatalf("original email was not released: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, replacementID) })
|
||||
if err = users.Delete(ownerID); !errors.Is(err, storage.ErrConflict) {
|
||||
t.Fatalf("owner deletion error: got %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user