Phase 0: backend + frontend scaffold, single-binary build
Implements the Pansy v1 scaffold (epic #20, phase 0). #1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config, gin server with slog + recovery + /api/v1/healthz, modernc SQLite store with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration runner, full initial schema (users, sessions, gardens, garden_shares, garden_objects, plants, plantings), domain structs + sentinel errors, graceful shutdown. #2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack Router/Query, typed /api/v1 fetch wrapper (ApiError carries status + body so later issues can read 409 conflict rows), dev proxy /api -> :8080, responsive app shell with stub pages for all five routes. #3 Single binary: //go:embed of the web build with a committed placeholder, SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched /api and missing assets), Makefile (web/build/dev/test), README quickstart + env var table. Verified: go build/vet/test clean (CGO off); binary migrates idempotently and serves healthz; web tsc + build clean; integrated binary serves the SPA, deep links, and correct cache headers; app mounts and navigates all five routes at desktop and 375px widths. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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")
|
||||
)
|
||||
|
||||
// Enumerated string values mirrored from the schema CHECK constraints.
|
||||
const (
|
||||
UnitMetric = "metric"
|
||||
UnitImperial = "imperial"
|
||||
|
||||
RoleViewer = "viewer"
|
||||
RoleEditor = "editor"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user