- 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)
174 lines
5.8 KiB
Markdown
174 lines
5.8 KiB
Markdown
# 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/<householdId>/<randomUUID>.<ext>`.
|
||
- Streams to MinIO via `putObject`.
|
||
- Returns `{ url: "/api/uploads/<key>" }`.
|
||
- `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`.
|