// 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" // Units a seed lot's quantity can be counted in (mirrors the schema CHECK). UnitSeeds = "seeds" UnitGrams = "grams" UnitOunces = "ounces" UnitPackets = "packets" UnitBulbs = "bulbs" UnitPlants = "plants" ) // 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"` } // JournalEntry is one time-stamped observation: what happened, and when. // Distinct from the mutable `notes` on a garden/object/plant, which says what // the thing IS — writing a new note overwrites the old one, while entries // accumulate. // // GardenID is set even when the entry is about a bed or a plop, so permission // checks reuse the garden role machinery unchanged; ObjectID/PlantingID narrow // the target without inventing a second ACL path. type JournalEntry struct { ID int64 `json:"id"` GardenID int64 `json:"gardenId"` ObjectID *int64 `json:"objectId,omitempty"` PlantingID *int64 `json:"plantingId,omitempty"` AuthorID int64 `json:"authorId"` Body string `json:"body"` // ObservedAt is when it happened ('YYYY-MM-DD'), which is not when it was // written down: you write up Saturday's observations on Sunday. ObservedAt string `json:"observedAt"` Version int64 `json:"version"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` // AuthorName is resolved for display by the service, never persisted. AuthorName string `json:"authorName,omitempty"` } // Roles a stored agent message can have. const ( AgentRoleUser = "user" AgentRoleAssistant = "assistant" ) // AgentMessage is one side of a chat exchange with the garden assistant. Only // the text is kept, not the model's full transcript with its tool calls: // continuity needs what was said and what came back, and replaying a stored tool // call would be replaying a decision made against a garden that has since moved // on. type AgentMessage struct { ID int64 `json:"id"` ConversationID int64 `json:"conversationId"` Role string `json:"role"` Body string `json:"body"` // ChangeSetID is what this turn changed, if anything — the handle the chat // panel needs to offer Undo on the message that caused it. ChangeSetID *int64 `json:"changeSetId,omitempty"` CreatedAt string `json:"createdAt"` } // 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" ) // InstanceSettings is the single row of instance-wide, admin-editable // configuration (#79). Secrets are deliberately absent — see migration 0010. // // Both agent fields express "inherit from the environment unless set": // AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits // the env default. EffectiveAgent resolves them against the environment. type InstanceSettings struct { // AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to // majordomo.Parse, exactly like the env var it shadows. AgentModel string `json:"agentModel"` // AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit. AgentEnabled *bool `json:"agentEnabled"` // VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that // reads a photographed seed packet (#81). Empty = feature off unless the env // var names one. VisionModel string `json:"visionModel"` Version int64 `json:"version"` UpdatedAt string `json:"updatedAt"` } // 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. // // SourceURL/Vendor are provenance for the variety itself — the page you'd go // back to in order to buy it again. What you actually bought, and how much is // left, lives in SeedLot instead: the same variety may come from two vendors in // two years, and a single pair of columns here would collapse that. 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"` SourceURL string `json:"sourceUrl"` Vendor string `json:"vendor"` Notes string `json:"notes"` Version int64 `json:"version"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } // SeedLot is one purchase: a specific packet of a specific variety, from a // specific vendor, in a specific year. Owned by the buyer even when it points at // a built-in plant, and never shared along with a garden. type SeedLot struct { ID int64 `json:"id"` OwnerID int64 `json:"ownerId"` PlantID int64 `json:"plantId"` Vendor string `json:"vendor"` SourceURL string `json:"sourceUrl"` SKU string `json:"sku"` LotCode string `json:"lotCode"` PurchasedAt *string `json:"purchasedAt,omitempty"` PackedForYear *int `json:"packedForYear,omitempty"` Quantity float64 `json:"quantity"` Unit string `json:"unit"` CostCents *int `json:"costCents,omitempty"` GerminationPct *float64 `json:"germinationPct,omitempty"` Notes string `json:"notes"` Version int64 `json:"version"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` // Used is the summed effective count of active plantings attributed to this // lot, and Remaining is Quantity - Used. Both are computed by the service and // never stored: a stored running total drifts the moment anything edits a // planting behind its back, and can't be audited afterwards. Used float64 `json:"used"` Remaining float64 `json:"remaining"` } // 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"` // SeedLotID attributes this planting to a purchase, so a lot can report what // is left. Nullable: most plantings never name a lot, and retiring a lot sets // this back to NULL rather than destroying planting history. SeedLotID *int64 `json:"seedLotId,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"` }