Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
321 lines
13 KiB
Go
321 lines
13 KiB
Go
// Package domain holds pansy's core types: plain structs mirroring the database
|
|
// schema and the sentinel errors the service layer returns. It has no
|
|
// dependencies on store, api, or any framework — every other package depends on
|
|
// it, not the other way around.
|
|
package domain
|
|
|
|
import "errors"
|
|
|
|
// Sentinel errors returned by the service layer and mapped to HTTP status codes
|
|
// by the API layer (404, 403, 409 respectively).
|
|
var (
|
|
// ErrNotFound means the requested entity does not exist (or is invisible to
|
|
// the actor, when we deliberately mask existence).
|
|
ErrNotFound = errors.New("not found")
|
|
// ErrForbidden means the actor is known but lacks permission for the action.
|
|
ErrForbidden = errors.New("forbidden")
|
|
// ErrVersionConflict means the row's version did not match the one supplied
|
|
// with a PATCH/DELETE; the caller should refetch and retry.
|
|
ErrVersionConflict = errors.New("version conflict")
|
|
// ErrPlantInUse means a plant can't be deleted because plantings still
|
|
// reference it (the FK is ON DELETE RESTRICT). Mapped to 409.
|
|
ErrPlantInUse = errors.New("plant is referenced by plantings")
|
|
|
|
// ErrShareUserNotFound means no account exists for the email a share invite
|
|
// targeted (v1 shares an existing user only — no invitation emails). Mapped to
|
|
// 404 with a clear "no account with that email" message.
|
|
ErrShareUserNotFound = errors.New("no account with that email")
|
|
// ErrCannotShareWithSelf means the owner tried to share a garden with their
|
|
// own account. Mapped to 400.
|
|
ErrCannotShareWithSelf = errors.New("cannot share a garden with yourself")
|
|
// ErrShareExists means the garden is already shared with that user. Mapped to
|
|
// 409 (change the existing share's role instead).
|
|
ErrShareExists = errors.New("garden already shared with that user")
|
|
|
|
// ErrInvalidInput means the caller supplied structurally invalid data (empty
|
|
// required field, malformed value). Mapped to 400.
|
|
ErrInvalidInput = errors.New("invalid input")
|
|
// ErrInvalidCredentials means a login attempt failed. It is deliberately
|
|
// identical for an unknown email and a wrong password so neither can be
|
|
// enumerated. Mapped to 401.
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
// ErrEmailTaken means registration collided with an existing account's email.
|
|
// Mapped to 409.
|
|
ErrEmailTaken = errors.New("email already registered")
|
|
// ErrRegistrationClosed means local self-service signup is disabled and at
|
|
// least one user already exists (the very first user may always register to
|
|
// bootstrap the instance). Mapped to 403.
|
|
ErrRegistrationClosed = errors.New("registration closed")
|
|
// ErrLocalAuthDisabled means PANSY_LOCAL_AUTH=false, so local register/login
|
|
// are rejected in favor of OIDC. Mapped to 403.
|
|
ErrLocalAuthDisabled = errors.New("local authentication disabled")
|
|
|
|
// ErrOIDCNoEmail means the IdP returned no email claim, so no account can be
|
|
// provisioned (email is the account's unique key). The email scope is required.
|
|
ErrOIDCNoEmail = errors.New("oidc identity has no email")
|
|
// ErrOIDCEmailUnverified means the IdP asserted an email it hasn't verified;
|
|
// pansy won't provision or link on an unverified email (it would enable
|
|
// account takeover / squatting).
|
|
ErrOIDCEmailUnverified = errors.New("oidc email not verified")
|
|
// ErrOIDCIdentityConflict means the OIDC identity can't be attached: either
|
|
// the target account already carries a different identity (refusing to
|
|
// overwrite it prevents lockout/takeover) or the (issuer, subject) pair is
|
|
// already bound to another account. Mapped to 409.
|
|
ErrOIDCIdentityConflict = errors.New("oidc identity conflict")
|
|
)
|
|
|
|
// Enumerated string values mirrored from the schema CHECK constraints.
|
|
const (
|
|
UnitMetric = "metric"
|
|
UnitImperial = "imperial"
|
|
|
|
RoleViewer = "viewer"
|
|
RoleEditor = "editor"
|
|
// RoleOwner is not a garden_shares row (ownership is implicit via
|
|
// gardens.owner_id); it's the value Garden.MyRole carries for an owner.
|
|
RoleOwner = "owner"
|
|
|
|
KindBed = "bed"
|
|
KindGrowBag = "grow_bag"
|
|
KindContainer = "container"
|
|
KindInGround = "in_ground"
|
|
KindTree = "tree"
|
|
KindPath = "path"
|
|
KindStructure = "structure"
|
|
|
|
ShapeRect = "rect"
|
|
ShapeCircle = "circle"
|
|
ShapePolygon = "polygon" // reserved for post-v1
|
|
|
|
CategoryVegetable = "vegetable"
|
|
CategoryHerb = "herb"
|
|
CategoryFlower = "flower"
|
|
CategoryFruit = "fruit"
|
|
CategoryTreeShrub = "tree_shrub"
|
|
CategoryCover = "cover"
|
|
|
|
// Where a change set came from. SourceAgent is what the history UI badges
|
|
// differently, so an autonomous edit is never mistaken for a hand edit.
|
|
SourceUI = "ui"
|
|
SourceAgent = "agent"
|
|
SourceAPI = "api"
|
|
|
|
// The entity types a revision can snapshot. Garden covers metadata edits
|
|
// only — garden creation and deletion are deliberately not revertible.
|
|
EntityGarden = "garden"
|
|
EntityObject = "object"
|
|
EntityPlanting = "planting"
|
|
|
|
OpCreate = "create"
|
|
OpUpdate = "update"
|
|
OpDelete = "delete"
|
|
)
|
|
|
|
// ChangeSet groups the revisions produced by one logical operation — the unit of
|
|
// undo. A revert is itself a change set (RevertsID names its target), so history
|
|
// is append-only and an undo can be undone.
|
|
type ChangeSet struct {
|
|
ID int64 `json:"id"`
|
|
GardenID int64 `json:"gardenId"`
|
|
ActorID int64 `json:"actorId"`
|
|
Source string `json:"source"`
|
|
Summary string `json:"summary"`
|
|
AgentRunID *string `json:"agentRunId,omitempty"`
|
|
RevertsID *int64 `json:"revertsId,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
|
|
// Computed by the service for the history list, never persisted.
|
|
ActorName string `json:"actorName,omitempty"`
|
|
// RevertedByID is the change set that reverted this one, if any — what lets
|
|
// the list mark an entry as already undone.
|
|
RevertedByID *int64 `json:"revertedById,omitempty"`
|
|
// Counts tallies the revisions by (entity type, op) so the list can say
|
|
// "3 plops added, 1 bed moved" without loading every revision.
|
|
Counts []ChangeCount `json:"counts,omitempty"`
|
|
// Revisions is populated only when a single change set is loaded in full.
|
|
Revisions []Revision `json:"revisions,omitempty"`
|
|
}
|
|
|
|
// ChangeCount is one (entity type, op) tally within a change set.
|
|
type ChangeCount struct {
|
|
EntityType string `json:"entityType"`
|
|
Op string `json:"op"`
|
|
N int `json:"n"`
|
|
}
|
|
|
|
// Revision is one row-level change within a change set. Before/After are JSON
|
|
// row snapshots (nil for create/delete respectively) and include the row's
|
|
// version, which the revert guard compares against.
|
|
type Revision struct {
|
|
ID int64 `json:"id"`
|
|
ChangeSetID int64 `json:"changeSetId"`
|
|
Seq int64 `json:"seq"`
|
|
EntityType string `json:"entityType"`
|
|
EntityID int64 `json:"entityId"`
|
|
Op string `json:"op"`
|
|
Before *string `json:"before,omitempty"`
|
|
After *string `json:"after,omitempty"`
|
|
}
|
|
|
|
// RevertConflict reports one entity a revert deliberately left alone, because
|
|
// reverting it would have discarded a change made after the change set being
|
|
// reverted. The rest of the change set still reverts.
|
|
type RevertConflict struct {
|
|
EntityType string `json:"entityType"`
|
|
EntityID int64 `json:"entityId"`
|
|
// Reason is one of the ConflictReason* values below.
|
|
Reason string `json:"reason"`
|
|
// Name is a human label for the entity where one exists (an object's name),
|
|
// so the UI can say "the north bed" rather than "object 41".
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// Why a revert skipped an entity.
|
|
const (
|
|
// ConflictChanged: the row was edited after the change set being reverted, so
|
|
// restoring the old snapshot would silently discard that edit.
|
|
ConflictChanged = "changed"
|
|
// ConflictMissing: the row is gone, so there is nothing to revert to.
|
|
ConflictMissing = "missing"
|
|
// ConflictExists: a row with that id exists again, so restoring the deleted
|
|
// snapshot would overwrite it.
|
|
ConflictExists = "exists"
|
|
// ConflictUnsupported: this kind of change has no inverse the revert knows how
|
|
// to apply. Reported rather than silently skipped, so a change recorded by a
|
|
// later feature that revert wasn't taught about is visible instead of quiet.
|
|
ConflictUnsupported = "unsupported"
|
|
)
|
|
|
|
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
PasswordHash *string `json:"-"` // never serialized
|
|
OIDCIssuer *string `json:"-"`
|
|
OIDCSubject *string `json:"-"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// Session is a server-side session record. The raw token is never stored; only
|
|
// its sha256 hash (the primary key) is.
|
|
type Session struct {
|
|
TokenHash string `json:"-"`
|
|
UserID int64 `json:"userId"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// Garden is a planning surface owned by one user, at real-world scale (cm).
|
|
type Garden struct {
|
|
ID int64 `json:"id"`
|
|
OwnerID int64 `json:"ownerId"`
|
|
Name string `json:"name"`
|
|
WidthCM float64 `json:"widthCm"`
|
|
HeightCM float64 `json:"heightCm"`
|
|
UnitPref string `json:"unitPref"`
|
|
Notes string `json:"notes"`
|
|
// GridSizeCM is the garden-scale grid spacing (cm) the editor draws its grid
|
|
// from; SnapToGrid, when true, snaps objects to that grid on place/move/resize.
|
|
GridSizeCM float64 `json:"gridSizeCm"`
|
|
SnapToGrid bool `json:"snapToGrid"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
|
|
// MyRole is the requesting actor's effective role on this garden ("owner",
|
|
// "editor", or "viewer"). Computed by the service, never persisted.
|
|
MyRole string `json:"myRole,omitempty"`
|
|
}
|
|
|
|
// GardenShare grants a non-owner user viewer or editor access to a garden.
|
|
type GardenShare struct {
|
|
ID int64 `json:"id"`
|
|
GardenID int64 `json:"gardenId"`
|
|
UserID int64 `json:"userId"`
|
|
Role string `json:"role"`
|
|
CreatedBy int64 `json:"createdBy"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// ShareWithUser is a garden share plus the target user's identity, for the
|
|
// sharing UI's list (which shows who a garden is shared with).
|
|
type ShareWithUser struct {
|
|
GardenShare
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
// GardenObject is any placeable object in a garden (bed, container, tree, path…).
|
|
// Positioned by center point + rotation about center, in garden space.
|
|
type GardenObject struct {
|
|
ID int64 `json:"id"`
|
|
GardenID int64 `json:"gardenId"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Shape string `json:"shape"`
|
|
Points *string `json:"points,omitempty"` // reserved: JSON for polygons
|
|
XCM float64 `json:"xCm"`
|
|
YCM float64 `json:"yCm"`
|
|
WidthCM float64 `json:"widthCm"`
|
|
HeightCM float64 `json:"heightCm"`
|
|
RotationDeg float64 `json:"rotationDeg"`
|
|
ZIndex int `json:"zIndex"`
|
|
Plantable bool `json:"plantable"`
|
|
Color *string `json:"color,omitempty"`
|
|
Props *string `json:"props,omitempty"` // kind-specific JSON
|
|
// GridSizeCM is this object's local grid spacing (cm); when SnapToGrid is true
|
|
// and the object is a plantable bed, plants snap to it inside the bed.
|
|
GridSizeCM float64 `json:"gridSizeCm"`
|
|
SnapToGrid bool `json:"snapToGrid"`
|
|
Notes string `json:"notes"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
|
|
type Plant struct {
|
|
ID int64 `json:"id"`
|
|
OwnerID *int64 `json:"ownerId,omitempty"` // nil = built-in
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
SpacingCM float64 `json:"spacingCm"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
|
|
Notes string `json:"notes"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// Planting ("plop") is a circular patch of one plant, positioned in its parent
|
|
// object's local frame. Count nil means it is derived from area / spacing².
|
|
type Planting struct {
|
|
ID int64 `json:"id"`
|
|
ObjectID int64 `json:"objectId"`
|
|
PlantID int64 `json:"plantId"`
|
|
XCM float64 `json:"xCm"`
|
|
YCM float64 `json:"yCm"`
|
|
RadiusCM float64 `json:"radiusCm"`
|
|
Count *int `json:"count,omitempty"` // nil = derived
|
|
Label *string `json:"label,omitempty"`
|
|
PlantedAt *string `json:"plantedAt,omitempty"`
|
|
RemovedAt *string `json:"removedAt,omitempty"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
|
|
// DerivedCount is the plant count implied by the plop's area and the plant's
|
|
// mature spacing: max(1, round(π·r² / spacing²)). It is computed by the
|
|
// service, never persisted (the store scan leaves it 0). The effective count
|
|
// a client shows is Count when non-nil, else DerivedCount.
|
|
DerivedCount int `json:"derivedCount"`
|
|
}
|