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
140 lines
7.8 KiB
SQL
140 lines
7.8 KiB
SQL
-- 0001_init.sql — pansy initial schema.
|
|
--
|
|
-- Conventions (see DESIGN.md § Domain model):
|
|
-- * All measurements are centimeters (REAL); imperial is a display concern.
|
|
-- * `version INTEGER NOT NULL DEFAULT 1` on every user-mutable row; PATCH/DELETE
|
|
-- carry it and the service layer increments on write for 409 conflict detection.
|
|
-- * Enumerations are enforced with CHECK constraints.
|
|
-- * Timestamps are ISO-8601 TEXT (UTC); dates are 'YYYY-MM-DD' TEXT.
|
|
-- * Foreign keys cascade on owner/parent deletion; enforcement is on (PRAGMA foreign_keys).
|
|
|
|
-- users -----------------------------------------------------------------------
|
|
-- A user may authenticate locally (password_hash), via OIDC (oidc_issuer+subject),
|
|
-- or both (linked by email on first OIDC login). First registered user is admin.
|
|
CREATE TABLE users (
|
|
id INTEGER PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
display_name TEXT NOT NULL,
|
|
password_hash TEXT, -- argon2id; NULL for OIDC-only users
|
|
oidc_issuer TEXT, -- NULL for local-only users
|
|
oidc_subject TEXT,
|
|
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
-- NULLs compare distinct in SQLite, so this only constrains real (issuer, subject) pairs.
|
|
CREATE UNIQUE INDEX idx_users_oidc ON users (oidc_issuer, oidc_subject);
|
|
|
|
-- sessions --------------------------------------------------------------------
|
|
-- Opaque bearer token stored only as a sha256 hash; the raw token lives in the
|
|
-- HttpOnly cookie. 30-day sliding expiry (expires_at bumped on use).
|
|
CREATE TABLE sessions (
|
|
token_hash TEXT PRIMARY KEY, -- sha256 of the 32-byte random token
|
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
CREATE INDEX idx_sessions_user ON sessions (user_id);
|
|
|
|
-- gardens ---------------------------------------------------------------------
|
|
CREATE TABLE gardens (
|
|
id INTEGER PRIMARY KEY,
|
|
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
width_cm REAL NOT NULL,
|
|
height_cm REAL NOT NULL,
|
|
unit_pref TEXT NOT NULL DEFAULT 'metric' CHECK (unit_pref IN ('metric', 'imperial')),
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
CREATE INDEX idx_gardens_owner ON gardens (owner_id);
|
|
|
|
-- garden_shares ---------------------------------------------------------------
|
|
-- The owner is implicit via gardens.owner_id and never has a share row.
|
|
CREATE TABLE garden_shares (
|
|
id INTEGER PRIMARY KEY,
|
|
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor')),
|
|
created_by INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
UNIQUE (garden_id, user_id)
|
|
);
|
|
CREATE INDEX idx_garden_shares_garden ON garden_shares (garden_id);
|
|
CREATE INDEX idx_garden_shares_user ON garden_shares (user_id);
|
|
|
|
-- garden_objects --------------------------------------------------------------
|
|
-- One polymorphic table for every placeable object. Positioned by CENTER point +
|
|
-- rotation about center, in garden space (origin top-left, x→right, y→down).
|
|
-- shape='polygon' and the points JSON column are reserved for post-v1; the v1
|
|
-- editor emits only 'rect' and 'circle' (circle: width_cm = diameter).
|
|
CREATE TABLE garden_objects (
|
|
id INTEGER PRIMARY KEY,
|
|
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure')),
|
|
name TEXT NOT NULL DEFAULT '',
|
|
shape TEXT NOT NULL DEFAULT 'rect' CHECK (shape IN ('rect', 'circle', 'polygon')),
|
|
points TEXT, -- reserved: JSON [[x,y],...] for shape='polygon'
|
|
x_cm REAL NOT NULL, -- center in garden space
|
|
y_cm REAL NOT NULL,
|
|
width_cm REAL NOT NULL,
|
|
height_cm REAL NOT NULL,
|
|
rotation_deg REAL NOT NULL DEFAULT 0, -- clockwise about center
|
|
z_index INTEGER NOT NULL DEFAULT 0,
|
|
plantable INTEGER NOT NULL DEFAULT 0 CHECK (plantable IN (0, 1)),
|
|
color TEXT, -- optional hex override
|
|
props TEXT, -- kind-specific extras (JSON)
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
CREATE INDEX idx_garden_objects_garden ON garden_objects (garden_id);
|
|
|
|
-- plants ----------------------------------------------------------------------
|
|
-- Catalog. owner_id NULL = built-in (seeded, read-only; clone to customize).
|
|
-- Users see built-ins plus their own.
|
|
CREATE TABLE plants (
|
|
id INTEGER PRIMARY KEY,
|
|
owner_id INTEGER REFERENCES users (id) ON DELETE CASCADE, -- NULL = built-in
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL CHECK (category IN ('vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover')),
|
|
spacing_cm REAL NOT NULL, -- mature spacing
|
|
color TEXT NOT NULL, -- hex
|
|
icon TEXT NOT NULL DEFAULT '', -- emoji (zero image assets in v1)
|
|
days_to_maturity INTEGER,
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
CREATE INDEX idx_plants_owner ON plants (owner_id);
|
|
|
|
-- plantings ("plops") ---------------------------------------------------------
|
|
-- A circular patch of one plant, positioned in the PARENT OBJECT's local frame
|
|
-- (origin at object center, unrotated axes) so moving/rotating the bed moves its
|
|
-- plants for free. count NULL = derived max(1, round(pi*r^2 / spacing_cm^2)).
|
|
-- "Clear bed" sets removed_at; v1 lists rows where removed_at IS NULL.
|
|
CREATE TABLE plantings (
|
|
id INTEGER PRIMARY KEY,
|
|
object_id INTEGER NOT NULL REFERENCES garden_objects (id) ON DELETE CASCADE,
|
|
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE RESTRICT,
|
|
x_cm REAL NOT NULL, -- center in object-local frame
|
|
y_cm REAL NOT NULL,
|
|
radius_cm REAL NOT NULL,
|
|
count INTEGER, -- NULL = derived from area / spacing^2
|
|
label TEXT,
|
|
planted_at TEXT, -- 'YYYY-MM-DD'
|
|
removed_at TEXT, -- 'YYYY-MM-DD'; NULL = currently planted
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
CREATE INDEX idx_plantings_object ON plantings (object_id);
|
|
CREATE INDEX idx_plantings_plant ON plantings (plant_id);
|