- 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)
5.8 KiB
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:
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
minioClientandMINIO_BUCKETconstant. - 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— acceptsmultipart/form-datawith a singlefilefield.- 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 usinggetObject. SetsCache-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
iduuid pkhousehold_idfk households cascadenametext not nulltypetext not null default'other'— free text, convention:'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'location_notestext nullablecover_image_urltext nullablecreated_at,updated_attimestamptz
Index on household_id.
garden_plants
iduuid pkhousehold_idfk households cascadecontainer_iduuid nullable fkgarden_containersset-null on deletenametext not nullscientific_nametext nullablespecies_idtext nullable — external Perenual species ID, stored as stringcategorytext not null default'other'— free text, convention:'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'notestext nullableacquisition_datedate nullablegrowth_stagetext nullable — convention:'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'health_statustext not null default'healthy'— convention:'healthy' | 'stressed' | 'sick' | 'dormant'sunlighttext nullablewatering_notestext nullablefertilizing_notestext nullableprimary_image_urltext nullableimagesjsonb not null default'[]'— typed asstring[], stores upload URL pathscreated_at,updated_attimestamptz
Indexes on (household_id), (household_id, container_id).
garden_care_logs
iduuid pkplant_iduuid fkgarden_plantscascadehousehold_idfk households cascadecare_typetext not null — convention:'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'performed_byuuid fk users set-null on deletenotestext nullableperformed_attimestamptz not null default now()created_attimestamptz
Indexes on (plant_id, performed_at desc), (household_id).
garden_care_schedules
iduuid pkplant_iduuid fkgarden_plantscascadehousehold_idfk households cascadecare_typetext not nullinterval_daysint not nulllast_performed_attimestamptz nullablenext_due_attimestamptz nullable — recomputed after every care log entryenabledboolean not null default truecreated_at,updated_attimestamptz
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", emptyentities: [])server/actions.ts(empty stub)server/queries.ts(empty stub)
Register in src/modules/index.ts:
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 miniostarts and passes health-check.POST /api/uploadsrejects unauthenticated requests with 401.POST /api/uploadsrejects files > 5 MB with 413.POST /api/uploadsaccepts a valid JPEG and returns{ url: "..." };GETof that URL returns the image bytes.pnpm db:migrateapplies cleanly; all four tables exist with the correct columns.gardenmodule appears in/debug/registry.