# 70 — Garden infrastructure (MinIO + upload route + schema) ## Goal Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration. No UI or business logic — just the plumbing that every subsequent garden task depends on. ## Depends on - 03 (Drizzle + Postgres), 07 (household seed) ## Scope ### compose.yaml additions (`deploy/compose.yaml`) Add a `minio` service and a `garden-uploads` named volume: ```yaml minio: image: minio/minio:latest command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} volumes: - garden-uploads:/data ports: - "9000:9000" # API - "9001:9001" # Console (dev only — restrict in prod) healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 30s timeout: 10s retries: 3 volumes: garden-uploads: ``` Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`. ### MinIO client (`src/lib/minio.ts`) Thin singleton using the `minio` npm package: - Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`. - Export `minioClient` and `MINIO_BUCKET` constant. - On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`). ### Upload API route (`src/app/api/uploads/route.ts`) - `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field. - Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not). - Generates a storage key: `garden//.`. - Streams to MinIO via `putObject`. - Returns `{ url: "/api/uploads/" }`. - `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`. This route is generic — not garden-specific. Other modules can reuse it. ### Schema (`src/modules/garden/schema.ts`) Four tables, all `household_id`-scoped: **`garden_containers`** - `id` uuid pk - `household_id` fk households cascade - `name` text not null - `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'` - `location_notes` text nullable - `cover_image_url` text nullable - `created_at`, `updated_at` timestamptz Index on `household_id`. **`garden_plants`** - `id` uuid pk - `household_id` fk households cascade - `container_id` uuid nullable fk `garden_containers` set-null on delete - `name` text not null - `scientific_name` text nullable - `species_id` text nullable — external Perenual species ID, stored as string - `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'` - `notes` text nullable - `acquisition_date` date nullable - `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'` - `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'` - `sunlight` text nullable - `watering_notes` text nullable - `fertilizing_notes` text nullable - `primary_image_url` text nullable - `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths - `created_at`, `updated_at` timestamptz Indexes on `(household_id)`, `(household_id, container_id)`. **`garden_care_logs`** - `id` uuid pk - `plant_id` uuid fk `garden_plants` cascade - `household_id` fk households cascade - `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'` - `performed_by` uuid fk users set-null on delete - `notes` text nullable - `performed_at` timestamptz not null default now() - `created_at` timestamptz Indexes on `(plant_id, performed_at desc)`, `(household_id)`. **`garden_care_schedules`** - `id` uuid pk - `plant_id` uuid fk `garden_plants` cascade - `household_id` fk households cascade - `care_type` text not null - `interval_days` int not null - `last_performed_at` timestamptz nullable - `next_due_at` timestamptz nullable — recomputed after every care log entry - `enabled` boolean not null default true - `created_at`, `updated_at` timestamptz Unique index on `(plant_id, care_type)` — one schedule per plant per care type. Index on `(household_id, next_due_at)` for dashboard queries. ### Drizzle migration Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply. ### Module scaffold (`src/modules/garden/`) Create the directory with stubs: - `schema.ts` (complete, from above) - `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`) - `server/actions.ts` (empty stub) - `server/queries.ts` (empty stub) Register in `src/modules/index.ts`: ```typescript import gardenManifest from "./garden/manifest"; registerModule(gardenManifest); ``` ### Environment Add to `.env.example`: ``` MINIO_ENDPOINT=http://localhost:9000 MINIO_ROOT_USER=famapp MINIO_ROOT_PASSWORD=changeme MINIO_BUCKET=garden PERENUAL_API_KEY= # filled in task 72 ``` ## Out of scope - Any garden-specific UI. - Perenual API integration (task 72). - Care logic (tasks 73–74). ## Acceptance criteria - [ ] `docker compose up minio` starts and passes health-check. - [ ] `POST /api/uploads` rejects unauthenticated requests with 401. - [ ] `POST /api/uploads` rejects files > 5 MB with 413. - [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes. - [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns. - [ ] `garden` module appears in `/debug/registry`.