Files
famapp/docs/tasks/70-garden-infrastructure.md
T
ginnoir 2fd0677c5f 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)
2026-06-01 19:23:19 -05:00

5.8 KiB
Raw Blame History

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 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:

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 7374).

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.