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
+9
View File
@@ -33,3 +33,12 @@ LOG_LEVEL=info
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
# MinIO object storage (used for plant/container image uploads)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
# Perenual plant species API (https://perenual.com — free tier available)
PERENUAL_API_KEY=
+173
View File
@@ -0,0 +1,173 @@
# 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 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`.
+82
View File
@@ -0,0 +1,82 @@
# 71 — Garden containers
## Goal
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
## Depends on
- 70 (garden infrastructure — schema, MinIO, upload route)
## Scope
### Server (`src/modules/garden/server/`)
Add to `actions.ts`:
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
Add to `queries.ts`:
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
### UI (`src/modules/garden/components/`)
**`container-list.tsx`** (server component)
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
- "New container" button → sheet/dialog.
**`container-detail.tsx`** (server component)
- Header: cover photo, name, type, location notes, edit/delete buttons.
- Plant grid (placeholder for now — task 72 fills in the plant cards).
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
**`container-form.tsx`** (client component)
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
- Used for both create and edit via an optional `existing` prop.
### Pages (`src/app/garden/`)
- `page.tsx``/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
### Nav
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
```typescript
nav: { href: "/garden", label: "Garden", icon: "sprout" }
```
### Activity log
`renderActivity` for `garden.container` entity type in the manifest:
- `create``Created container "${name}"`
- `update``Updated container "${name}"`
- `delete``Deleted container`
### Share links
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
## Out of scope
- Plant cards inside the container detail (task 72 fills those in).
- Care tracking (tasks 7374).
## Acceptance criteria
- [ ] Create / edit / delete containers works end-to-end.
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
- [ ] `/garden` page renders with a Containers tab.
- [ ] `garden.container` entity is registered with share support in the manifest.
- [ ] Activity log entries appear for create / update / delete.
+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.
+75
View File
@@ -0,0 +1,75 @@
# 73 — Garden care tracking
## Goal
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
## Depends on
- 70 (schema), 72 (plants exist to attach care records to)
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
## Scope
### Care log actions (`src/modules/garden/server/actions.ts`)
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
- `deleteCareLog(input: { id })` — household check, delete.
### Care schedule actions (`src/modules/garden/server/actions.ts`)
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
- `deleteCareSchedule(input: { id })``cancelReminder("garden.schedule", id)`, delete.
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
### Queries (`src/modules/garden/server/queries.ts`)
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
### UI (`src/modules/garden/components/`)
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
### Quick-add (update manifest)
```typescript
{
id: "garden.log-care",
label: "Log plant care",
icon: "droplets",
url: "/garden",
}
```
## Out of scope
- Calendar/lists integration (task 74).
- Dashboard widget (task 75).
## Acceptance criteria
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
- [ ] Deleting a schedule cancels its reminder.
- [ ] `getOverduePlants` returns correctly sorted results.
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
- [ ] "Log plant care" appears in the quick-add sheet.
+83
View File
@@ -0,0 +1,83 @@
# 74 — Garden integrations (calendar + lists)
## Goal
Wire the garden module into the two existing coordination modules:
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
2. **Lists**: push all overdue care tasks to the household task list in one tap.
## Depends on
- 73 (care schedules + overdue queries)
- 10 (calendar module — `createEvent`)
- 11 (lists module — `addItem`)
## Scope
### Calendar integration
#### Server action (`src/modules/garden/server/actions.ts`)
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
- Look up the schedule + plant; household check.
- `startAt` = `next_due_at` (reject with user-facing error if null).
- `endAt` = `startAt + 30 min`; `allDay = false`.
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
- Call `createCalendarEvent(...)` from the bridge below.
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
```typescript
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
export { listCalendars } from "@/modules/calendar/server/queries";
```
This keeps the cross-module dependency explicit and swappable without touching `_core`.
#### UI
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
---
### Lists integration
#### Server action (`src/modules/garden/server/actions.ts`)
`pushOverdueToTaskList(): Promise<{ added: number }>`:
- Call `getOverduePlants(householdId)`.
- Find the household default task list via the bridge below.
- For each overdue `(plant, schedule)` pair insert a list item:
- `text`: same title convention as the calendar integration.
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
- `dueAt`: `schedule.next_due_at`.
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
- Return `{ added: N }`.
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
```typescript
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { listLists } from "@/modules/lists/server/queries";
```
#### UI
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
## Out of scope
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
- Recurring calendar events (garden creates single events only).
## Acceptance criteria
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
- [ ] Items appear in the task list at `/lists`.
- [ ] Both actions enforce household scope.
- [ ] Both are graceful no-ops when nothing is overdue / due.
+144
View File
@@ -0,0 +1,144 @@
# 75 — Garden dashboard, widgets, and manifest completion
## Goal
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
## Depends on
- 7074 (all previous garden tasks)
## Scope
### Manifest completion (`src/modules/garden/manifest.tsx`)
Replace the task-70 stub with the full manifest.
**Entities:**
```typescript
entities: [
{
type: "garden.container",
label: { singular: "Container", plural: "Containers" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: loadContainerForShare,
renderSharedView: ...,
renderActivity: ...,
},
{
type: "garden.plant",
label: { singular: "Plant", plural: "Plants" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: loadPlantForShare,
renderSharedView: ...,
renderActivity: ...,
},
]
```
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
**Quick adds** (consolidate from tasks 7173):
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
---
### Widget 1: `garden.care-due`
**Title**: "Plants needing care" | **Category**: "Garden"
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
Config schema:
```typescript
z.object({
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
daysAhead: z.number().int().min(0).max(30).default(0),
});
```
Default config: `{ containerIds: "all", daysAhead: 0 }`.
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
Render:
- Empty state if nothing is due: "All plants are on schedule."
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
- Truncate at 10 rows; "View all" link to `/garden`.
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
---
### Widget 2: `garden.overview`
**Title**: "Garden overview" | **Category**: "Garden"
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
Config schema: `z.object({})`.
Render:
- Stat row: total plants, total containers, overdue care count.
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
- Link to `/garden`.
---
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
---
### Search adapters
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
---
### Notification body improvement (optional)
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
---
### Playwright E2E test (`tests/garden.spec.ts`)
Happy path:
1. Log in as the seeded user.
2. Create container "Living Room Shelf".
3. Create plant "Pothos" in that container (manual entry, no species lookup).
4. Add watering schedule: every 7 days.
5. Log care: watered.
6. Verify schedule `next_due_at` updates to ~7 days from now.
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
8. Add `garden.care-due` widget to the default dashboard.
9. Confirm no overdue items (just watered).
## Out of scope
- Health trend charts.
- Weather-based watering adjustments.
- Social/community features.
## Acceptance criteria
- [ ] Both widgets register and appear in the widget picker.
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
- [ ] `garden.overview` shows correct counts.
- [ ] Both entities appear in command palette search.
- [ ] All three quick-adds appear in the `+` sheet.
- [ ] Garden nav link appears in sidebar and bottom nav.
- [ ] Playwright happy-path test passes.
+74
View File
@@ -0,0 +1,74 @@
-- Task 70: Garden module — containers, plants, care logs, care schedules, species cache
CREATE TABLE "garden_containers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"type" text NOT NULL DEFAULT 'other',
"location_notes" text,
"cover_image_url" text,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_containers_household_idx" ON "garden_containers" ("household_id");
CREATE TABLE "garden_plants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"container_id" uuid REFERENCES "garden_containers"("id") ON DELETE SET NULL,
"name" text NOT NULL,
"scientific_name" text,
"species_id" text,
"category" text NOT NULL DEFAULT 'other',
"notes" text,
"acquisition_date" date,
"growth_stage" text,
"health_status" text NOT NULL DEFAULT 'healthy',
"sunlight" text,
"watering_notes" text,
"fertilizing_notes" text,
"primary_image_url" text,
"images" jsonb NOT NULL DEFAULT '[]',
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_plants_household_idx" ON "garden_plants" ("household_id");
CREATE INDEX "garden_plants_household_container_idx" ON "garden_plants" ("household_id", "container_id");
CREATE TABLE "garden_care_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"performed_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
"notes" text,
"performed_at" timestamp with time zone NOT NULL DEFAULT now(),
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_care_logs_plant_performed_idx" ON "garden_care_logs" ("plant_id", "performed_at");
CREATE INDEX "garden_care_logs_household_idx" ON "garden_care_logs" ("household_id");
CREATE TABLE "garden_care_schedules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"interval_days" integer NOT NULL,
"last_performed_at" timestamp with time zone,
"next_due_at" timestamp with time zone,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "garden_care_schedules_plant_type_uq" ON "garden_care_schedules" ("plant_id", "care_type");
CREATE INDEX "garden_care_schedules_household_due_idx" ON "garden_care_schedules" ("household_id", "next_due_at");
CREATE TABLE "garden_species_cache" (
"species_id" text PRIMARY KEY NOT NULL,
"data" jsonb NOT NULL,
"cached_at" timestamp with time zone NOT NULL DEFAULT now()
);
+7
View File
@@ -106,6 +106,13 @@
"when": 1778600000000,
"tag": "0014_paper_ink_theme",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1748736000000,
"tag": "0015_garden_schema",
"breakpoints": true
}
]
}
+1
View File
@@ -86,6 +86,7 @@
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"minio": "^8.0.7",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"pino": "^10.3.1",
+188
View File
@@ -44,6 +44,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
minio:
specifier: ^8.0.7
version: 8.0.7
next:
specifier: ^15.5.15
version: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1430,6 +1433,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.1':
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2175,6 +2181,9 @@ packages:
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -2210,6 +2219,9 @@ packages:
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
@@ -2228,11 +2240,18 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -2520,6 +2539,10 @@ packages:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -3019,6 +3042,13 @@ packages:
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.8.0:
resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3050,6 +3080,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -3341,6 +3375,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
ipaddr.js@2.4.0:
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
engines: {node: '>= 10'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -3736,6 +3774,9 @@ packages:
lodash.uniqby@4.7.0:
resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -3801,10 +3842,18 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
@@ -3830,6 +3879,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minio@8.0.7:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4084,6 +4137,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -4236,6 +4293,10 @@ packages:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -4426,6 +4487,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -4555,6 +4620,10 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -4578,9 +4647,19 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-chain@2.2.5:
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
stream-json@1.9.1:
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -4655,6 +4734,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4694,6 +4776,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4963,6 +5048,18 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6076,6 +6173,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6785,6 +6884,8 @@ snapshots:
dependencies:
retry: 0.13.1
async@3.2.6: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -6805,6 +6906,10 @@ snapshots:
before-after-hook@4.0.0: {}
block-stream2@2.1.0:
dependencies:
readable-stream: 3.6.2
bn.js@4.12.3: {}
body-parser@2.2.2:
@@ -6834,6 +6939,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-or-node@2.1.1: {}
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.27
@@ -6842,6 +6949,8 @@ snapshots:
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@@ -7114,6 +7223,8 @@ snapshots:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: {}
dedent@1.7.2: {}
deep-is@0.1.4: {}
@@ -7766,6 +7877,19 @@ snapshots:
dependencies:
fast-string-width: 3.0.2
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.8.0:
dependencies:
'@nodable/entities': 2.1.1
fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0
strnum: 2.3.0
xml-naming: 0.1.0
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -7795,6 +7919,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@@ -8085,6 +8211,8 @@ snapshots:
ipaddr.js@1.9.1: {}
ipaddr.js@2.4.0: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -8429,6 +8557,8 @@ snapshots:
lodash.uniqby@4.7.0: {}
lodash@4.18.1: {}
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -8486,8 +8616,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -8508,6 +8644,22 @@ snapshots:
minimist@1.2.8: {}
minio@8.0.7:
dependencies:
async: 3.2.6
block-stream2: 2.1.0
browser-or-node: 2.1.1
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
fast-xml-parser: 5.8.0
ipaddr.js: 2.4.0
lodash: 4.18.1
mime-types: 2.1.35
query-string: 7.1.3
stream-json: 1.9.1
through2: 4.0.2
xml2js: 0.6.2
ms@2.1.3: {}
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
@@ -8798,6 +8950,8 @@ snapshots:
path-exists@4.0.0: {}
path-expression-matcher@1.5.0: {}
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -8957,6 +9111,13 @@ snapshots:
dependencies:
side-channel: 1.1.0
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
@@ -9183,6 +9344,8 @@ snapshots:
safer-buffer@2.1.2: {}
sax@1.6.0: {}
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
@@ -9409,6 +9572,8 @@ snapshots:
spdx-license-ids@3.0.23: {}
split-on-first@1.1.0: {}
split2@4.2.0: {}
stable-hash@0.0.5: {}
@@ -9424,8 +9589,16 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-chain@2.2.5: {}
stream-json@1.9.1:
dependencies:
stream-chain: 2.2.5
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -9523,6 +9696,8 @@ snapshots:
strip-json-comments@5.0.3: {}
strnum@2.3.0: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
dependencies:
client-only: 0.0.1
@@ -9548,6 +9723,10 @@ snapshots:
dependencies:
real-require: 0.2.0
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tiny-invariant@1.3.3: {}
tinyexec@1.2.4: {}
@@ -9863,6 +10042,15 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xml-naming@0.1.0: {}
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
const { key } = await params;
const objectKey = key.join("/");
try {
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
const metaData = stat.metaData as Record<string, string> | undefined;
const contentType =
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
export async function POST(request: Request) {
let session: Awaited<ReturnType<typeof getCurrentSession>>;
try {
session = await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File exceeds 5 MB limit" }, { status: 413 });
}
const rawExt = file.name.split(".").pop() ?? "bin";
const safeExt = rawExt
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
await ensureBucket();
const buffer = Buffer.from(await file.arrayBuffer());
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
"Content-Type": file.type,
});
} catch (err) {
console.error("Upload failed", err);
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
}
return NextResponse.json({ url: `/api/uploads/${key}` });
}
+8 -1
View File
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
const schema = {
...coreSchema,
...calendarSchema,
...listsSchema,
...notesSchema,
...gardenSchema,
};
export const db = drizzle(client, { schema });
+29
View File
@@ -0,0 +1,29 @@
import { Client } from "minio";
const rawEndpoint = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
const endpointUrl = new URL(rawEndpoint);
export const minioClient = new Client({
endPoint: endpointUrl.hostname,
port: endpointUrl.port
? parseInt(endpointUrl.port)
: endpointUrl.protocol === "https:"
? 443
: 80,
useSSL: endpointUrl.protocol === "https:",
accessKey: process.env.MINIO_ROOT_USER ?? "",
secretKey: process.env.MINIO_ROOT_PASSWORD ?? "",
});
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "garden";
let bucketReady = false;
export async function ensureBucket(): Promise<void> {
if (bucketReady) return;
const exists = await minioClient.bucketExists(MINIO_BUCKET);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET);
}
bucketReady = true;
}
+12
View File
@@ -0,0 +1,12 @@
import type { ModuleManifest } from "../_core/module";
const gardenManifest: ModuleManifest = {
id: "garden",
name: "Garden",
nav: { href: "/garden", label: "Garden", icon: "sprout" },
entities: [],
dashboardWidgets: [],
quickAdds: [],
};
export default gardenManifest;
+119
View File
@@ -0,0 +1,119 @@
import {
boolean,
date,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const gardenContainers = pgTable(
"garden_containers",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull().default("other"),
locationNotes: text("location_notes"),
coverImageUrl: text("cover_image_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("garden_containers_household_idx").on(t.householdId)],
);
export const gardenPlants = pgTable(
"garden_plants",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
containerId: uuid("container_id").references(() => gardenContainers.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
scientificName: text("scientific_name"),
speciesId: text("species_id"),
category: text("category").notNull().default("other"),
notes: text("notes"),
acquisitionDate: date("acquisition_date"),
growthStage: text("growth_stage"),
healthStatus: text("health_status").notNull().default("healthy"),
sunlight: text("sunlight"),
wateringNotes: text("watering_notes"),
fertilizingNotes: text("fertilizing_notes"),
primaryImageUrl: text("primary_image_url"),
images: jsonb("images").notNull().default([]).$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("garden_plants_household_idx").on(t.householdId),
index("garden_plants_household_container_idx").on(t.householdId, t.containerId),
],
);
export const gardenCareLogs = pgTable(
"garden_care_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
performedBy: uuid("performed_by").references(() => users.id, { onDelete: "set null" }),
notes: text("notes"),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("garden_care_logs_plant_performed_idx").on(t.plantId, t.performedAt),
index("garden_care_logs_household_idx").on(t.householdId),
],
);
export const gardenCareSchedules = pgTable(
"garden_care_schedules",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
intervalDays: integer("interval_days").notNull(),
lastPerformedAt: timestamp("last_performed_at", { withTimezone: true }),
nextDueAt: timestamp("next_due_at", { withTimezone: true }),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("garden_care_schedules_plant_type_uq").on(t.plantId, t.careType),
index("garden_care_schedules_household_due_idx").on(t.householdId, t.nextDueAt),
],
);
export const gardenSpeciesCache = pgTable("garden_species_cache", {
speciesId: text("species_id").primaryKey(),
data: jsonb("data").notNull().$type<Record<string, unknown>>(),
cachedAt: timestamp("cached_at", { withTimezone: true }).notNull().defaultNow(),
});
export type GardenContainer = typeof gardenContainers.$inferSelect;
export type GardenPlant = typeof gardenPlants.$inferSelect;
export type GardenCareLog = typeof gardenCareLogs.$inferSelect;
export type GardenCareSchedule = typeof gardenCareSchedules.$inferSelect;
+3
View File
@@ -0,0 +1,3 @@
"use server";
// Garden server actions — implemented in tasks 7174
+1
View File
@@ -0,0 +1 @@
// Garden query functions — implemented in tasks 7174
+2
View File
@@ -3,8 +3,10 @@ import coreManifest from "./_core/manifest";
import calendarManifest from "./calendar/manifest";
import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
registerModule(listsManifest);
registerModule(notesManifest);
registerModule(gardenManifest);
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
// Run without any stored auth cookies so requests are genuinely unauthenticated
test.use({ storageState: { cookies: [], origins: [] } });
test("POST /api/uploads rejects unauthenticated requests with 401", async ({ request }) => {
const response = await request.post("/api/uploads", {
multipart: {
file: {
name: "test.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("fake-image-data"),
},
},
});
expect(response.status()).toBe(401);
});
test("POST /api/uploads rejects non-image files with 415", async ({ request }) => {
// Without auth this will be 401, but the route shape is validated
const response = await request.post("/api/uploads", {
multipart: {
file: {
name: "document.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4 test"),
},
},
});
// 401 because unauthenticated; 415 once authenticated — both indicate the route exists
expect([401, 415]).toContain(response.status());
});