feat: add garden module infrastructure (task 70)

- Add MinIO service to compose.yaml with named volume
- Add generic /api/uploads POST+GET route with auth, 5 MB, image/* guards
- Add src/lib/minio.ts singleton client with ensureBucket helper
- Add garden Drizzle schema: containers, plants, care_logs, care_schedules, species_cache
- Register garden module in src/modules/index.ts and src/lib/db.ts
- Apply migration 0015_garden_schema.sql
- Add MINIO_* and PERENUAL_API_KEY to .env.example
- Add task briefs 70-75 for the full garden feature arc
- Add uploads.spec.ts E2E test (RED → GREEN on route auth guard)
This commit is contained in:
ginnoir
2026-06-01 19:23:19 -05:00
parent 5dcd49d4c9
commit 2fd0677c5f
21 changed files with 1250 additions and 1 deletions
+117
View File
@@ -0,0 +1,117 @@
# 72 — Garden plants
## Goal
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
## Depends on
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
## Scope
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
Implement:
```typescript
export type SpeciesSuggestion = {
id: string;
common_name: string;
scientific_name: string;
watering: string; // e.g. "frequent", "average", "minimum"
sunlight: string[]; // e.g. ["full sun", "part shade"]
cycle: string; // e.g. "Perennial"
default_image_url: string | null;
};
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
```
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
### Server (`src/modules/garden/server/`)
Add to `actions.ts`:
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
Add to `queries.ts`:
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
### UI (`src/modules/garden/components/`)
**`plant-list.tsx`** (server component)
- Grouped by container first; "Unassigned" group for plants with no container.
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
**`plant-detail.tsx`** (server component)
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
- Info tab: all fields, container link, health/stage badges, species info.
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
**`plant-form.tsx`** (client component)
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
- Care notes textareas: watering notes, fertilizing notes, general notes.
- Acquisition date picker.
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
**`species-search.tsx`** (client component)
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
### Pages (`src/app/garden/`)
- `plants/new/page.tsx``<PlantForm />` in create mode.
- `plants/[id]/page.tsx``<PlantDetail />` with edit/delete controls.
- `plants/[id]/edit/page.tsx``<PlantForm />` in edit mode.
### Activity log
`renderActivity` for `garden.plant`:
- `create``Added plant "${name}"`
- `update``Updated "${name}"`
- `delete``Removed plant "${name}"`
### Search
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
### Share links
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
## Out of scope
- Care log UI and schedules (task 73).
- Dashboard widgets (task 75).
## Acceptance criteria
- [ ] Create / edit / delete plants works end-to-end.
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
- [ ] `garden.plant` entity is registered with search and share support.
- [ ] Activity log entries appear for create / update / delete.
- [ ] Plant share link renders the read-only view correctly.