Compare commits

...

6 Commits

Author SHA1 Message Date
777569a21a docs: add README, refresh CLAUDE.md for current modules + git-checkout deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:18:06 -05:00
b0118d3fec v0.13.0 — Inventory (HomeBox): durable items that nest
The first inventory vertical on the shared spine. Things you own — tools, gear,
equipment — each living in a Location or inside another Item.

- Item model (kind DURABLE|CONSUMABLE; consumable fields present but unused so
  Pantry needs no migration), self-nesting via parentItemId (toolbox holds drill,
  with a cycle guard), value/warranty/serial/brand/qty, qrSlug, photo
- Label (m2m) + ItemAttachment (photos/manuals/receipts) + ItemLog
  (maintenance/repair/move/dispose) history; Task.itemId
- API-first: /api/v1/items (CRUD), labels, logs, multipart attachments — the UI
  consumes the same scoped endpoints. items service shared with the coming MCP
- Inventory pages: tree grouped by Location with items nested inside items;
  item detail with warranty status, photos, contents, history; add/edit/delete,
  log, photo upload. Nav entry. Backup/restore cover the new tables
- Pure value.ts (warrantyStatus, totalValue) with tests; verified end-to-end
  against a running server (nest, cycle-guard 400, log 201)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:12:03 -05:00
0904790dca Security: force first-login password change, gate admin tools, validate restore
Fixes from the code review:
- #1 Wire mustChangePassword: dashboard layout redirects to a new /change-password
  page + /api/account/change-password (verifies current pw, re-hashes, clears the
  flag, refreshes the session). Provisioned admins can no longer keep the default.
- #2 Assert NEXTAUTH_SECRET in production (not during build); set it on authOptions.
- #3 Backup page now server-gated to admins (split into server page + client panel),
  matching the Updates page.
- #4 Restore validates input: strict zod schema for the users table (known fields,
  role enum) + array checks for the rest; returns generic errors instead of String(err).

No version bump/changelog — left for you to fold into the next release (you're
editing those live). Not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:59:39 -05:00
c5bf108cb9 v0.12.0 — REST API + personal access tokens
A bidirectional API so other apps (Home Assistant, phone shortcuts, scripts,
and the coming MCP server) can read and update the homestead.

- ApiToken model (sha256-hashed, scoped, with prefix/lastUsed/expiry/revoke)
- authenticateRequest() accepts EITHER a NextAuth cookie session OR a Bearer
  PAT; cookie sessions get full access, tokens are limited to their scopes
  (write implies read; resource and global wildcards). ApiError + handleApiError
- Shared service layer (plants/tasks/locations/plant-types) reused by every
  endpoint and, later, the MCP server — logic lives once
- /api/v1: plants (read + add log), plant-types, locations, tasks (read/create/
  complete), and a whoami/discovery root
- Settings → API tokens: create with per-resource read/write checkboxes, raw
  token shown once, revoke; tokens added to backup/restore
- Pure tests for scope matching + token hashing; verified end-to-end (401/403/
  201, lastUsedAt) against a running server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:55:58 -05:00
cdb21ed58a v0.11.0 — Plant types: the reusable "kind" record
A rich-but-all-optional reference record per kind of plant (care, sun, water,
bloom & harvest windows, toxic-to-pets, edible parts, spacing), distinct from a
specific planting or a group.

- PlantType model + optional Plant.plantTypeId; migration + idempotent backfill
  that seeds 247 kinds from the built-in plant library and links plantings by
  species/common name (wired into the deploy entrypoint)
- /api/plant-types CRUD; new "Plant types" section (browse by category, edit a
  kind, see every spot you've planted it)
- New plants auto-link to their type on create; plant detail shows "More about…"
- PlantType added to backup/restore; bump APP_VERSION 0.11.0 + changelog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:46:07 -05:00
9c0312676a v0.10.0 — Location spine: garden moved onto a shared place tree
Completes the Location feature (schema + migration landed earlier under 98cabc5).

- Hierarchical Location model is the shared spine; unlimited nesting
- Garden add/edit now picks a reusable Location (inline create) instead of
  freeform text; garden groups By Location off the real tree
- Dual-write keeps legacy Plant.zone in sync with location.name (Release A)
- /api/locations CRUD + /api/locations/[id]/log (logs a location + its sub-locations)
- Location tree pure-core (buildTree/computePath/descendantIds) with tests
- Location added to backup/restore export
- Bump APP_VERSION 0.10.0 + plain-English changelog entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:36:54 -05:00
80 changed files with 4791 additions and 302 deletions

108
CLAUDE.md
View File

@@ -1,73 +1,75 @@
# Moon Base — Project Notes
Property management for the Moon homestead (Eau Claire, WI) — food forest plant
registry, home maintenance tracking, and equipment logs. Next.js 14 + Prisma +
Property management for the Moon homestead (Eau Claire, WI). Next.js 14 + Prisma +
Postgres. Same conventions as FMB (`/Users/bonnamoon/Projects/fullmoonbakehouse`).
Repo on Gitea: **`bonna61/Moonbase`**. Live at NAS:8053. See `README.md` for the
user-facing overview.
## Modules
## Modules (all live)
- **v0.1 — Garden:** food forest plant registry + care/harvest log. Live.
- **Home maintenance** — planned next: systems, recurring tasks, repair history.
- **Equipment** — planned: tools/machines, service intervals, maintenance logs.
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
- **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s
(M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine.
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants + items.
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`.
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
- **Self-update** — admin "Pull & rebuild" (see Deploy).
## Local dev
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`
- Copy `.env.example``.env` and fill in values.
- `npm install` then `npm run dev` (port 3000)
- After schema edits: `npx prisma generate` then restart dev server.
- Seed demo data: `npm run db:seed` (creates Bonna + Tony accounts + sample plants)
- Minimal seed (admin accounts only): `SEED_MODE=minimal npm run db:seed`
- Apply migrations: `npx prisma migrate deploy`
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example``.env`.
- `npm install`, `npm run dev` (3000). After schema edits: `npx prisma generate`.
- `npx prisma migrate deploy` provisions Bonna + Tony. `npm run db:seed` for demo
data (`SEED_MODE=minimal` = accounts only).
- Backfills for the location/plant-type refactors: `npm run db:backfill-locations`,
`npm run db:backfill-plant-types`.
## First-run setup (brand new machine / fresh DB)
## Deploy — git checkout on the NAS (NOT rsync)
1. `npm install`
2. Create `.env` from `.env.example`
3. `npx prisma migrate deploy` — creates the DB schema + provisions Bonna and Tony accounts
4. Default password for both is `moonbase!` with `mustChangePassword=true` — change on first login.
The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
`origin/master` (remote URL carries the token). Deploy by:
```sh
git push origin master
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
```
Or admins click **Settings → Updates → Pull & rebuild**, which signals the
privileged `updater` sidecar (docker socket + adnanh/webhook) to run the same
`git pull && compose up -d --build app` detached; the UI polls `/api/version` and
reloads. **Never rsync over the appdata dir** — it fights the checkout.
- **Secrets** (`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a
gitignored host `.env` next to the compose file, interpolated in — never committed.
- **Docker runtime** mirrors FMB: `node:20-bookworm-slim` + openssl, runs as root,
full `node_modules`, `next start`. Migrations run on container start via the
entrypoint (waits for Postgres first). Image gotcha: don't let `npx prisma`
resolve at runtime — with no local prisma on PATH it pulls Prisma 7, which
rejects `url` in the datasource.
## Conventions (same as FMB)
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
entry at the top of `src/lib/changelog.ts`. Minor (0.X.0) for features/migrations,
patch for fixes. Write changelog for Bonna, not engineers.
- **AuditEvent on every mutation** — append-only, never delete.
- **Soft-delete** via `active=false` for Plant (keeps log history intact).
- **Pure core + colocated test** — new logic goes in `src/lib/...` as a pure
function with a `.test.ts` next to it; API routes stay thin.
- **Worktrees for parallel sessions** — same pattern as FMB. Use a worktree
(`git worktree add ../moonbase-<slug> -b <slug>`) if running two Claude sessions.
## Git remote
Add Gitea remote once the repo is created there:
```sh
git remote add gitea http://192.168.0.5:3022/tonym/moonbase.git
git push gitea main
```
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
- **AuditEvent on every mutation** — append-only.
- **Soft-delete** via `active=false`.
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
## Auth
- Username OR email login (same as FMB). Accounts: `bonna` / `tony`, role ADMIN.
- `mustChangePassword=true` on provisioned accounts — user is prompted on next login
(add that redirect to `src/app/(dashboard)/layout.tsx` when implementing it).
- `getSession()` / `isAdmin()` in `src/lib/auth.ts`.
## Garden module — data model
- **Plant** — commonName, species (scientific), variety, category (PlantCategory enum),
zone (freeform text: "Back yard north fence"), plantedAt, source, notes, active.
- **PlantLog** — per-plant log: type (OBSERVATION / CARE / HARVEST / TREATMENT /
TRANSPLANT / DIED), date, notes, quantity (freeform, for harvests).
- Category labels + log type labels/colors live in `src/lib/garden/categories.ts`.
- API: `POST /api/plants` (create plant), `POST /api/plants/[id]/logs` (add log entry).
- Username OR email login. Accounts `bonna` / `tony`, role `ADMIN`.
- `getSession()` / `requireAuth()` / `isAdmin()` in `src/lib/auth.ts`.
- **Forced password change is wired** (provisioned accounts ship `moonbase!` +
`mustChangePassword=true`): the dashboard layout redirects to `/change-password`
(outside the layout group; backed by `/api/account/change-password`). Don't
remove that redirect.
- `NEXTAUTH_SECRET` is asserted at runtime in production (skipped during build).
## Color palette
Forest / earth theme. Key CSS variables:
- `--canopy` — dark forest green sidebar
- `--leaf` — fresh green accent
- `--soil` / `--soil-muted` — body text
- `--dew` — pale sage background
- `--ember` — red for destructive actions
Forest / earth theme: `--canopy` (sidebar), `--leaf` (accent), `--soil` (text),
`--dew` (bg), `--ember` (destructive).

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# Moon Base
Property management for the Moon homestead (Eau Claire, WI). A single place to
track the food forest, household inventory, and recurring upkeep — built as a
Next.js 14 + Prisma + Postgres app, deployed self-hosted on the home NAS.
Live at **http://192.168.0.5:8053**.
## Modules
- **Garden** — plant registry with care/harvest/observation logs, plant **groups**,
reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
location tree.
- **Inventory** — durable **items** that nest (boxes within rooms within
buildings), with labels, attachments, and logs. Shares the same location spine
as the garden.
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
optionally linked to a plant.
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
and automation.
- **Suggestions** — in-app feedback that files issues to Gitea (`@otm/account-panel`).
- **Backup & Restore** — download/restore the whole dataset as a zip (admin only).
- **Self-update** — admins get a "Pull & rebuild" button that updates the running
app to the latest pushed version (see Deploy).
## Tech
Next.js 14 (App Router) · Prisma · PostgreSQL · NextAuth (credentials) ·
Tailwind + shadcn/ui · Docker Compose.
## Local dev
```sh
npm install
cp .env.example .env # fill in DATABASE_URL + NEXTAUTH_SECRET
npx prisma migrate deploy # schema + provisions bonna/tony accounts
npm run db:seed # optional demo data (SEED_MODE=minimal for accounts only)
npm run dev # http://localhost:3000
```
After schema edits: `npx prisma generate` then restart. Data backfills for the
location/plant-type refactors: `npm run db:backfill-locations`,
`npm run db:backfill-plant-types`.
## Auth / first run
Username **or** email login. Provisioned accounts `bonna` / `tony` (role `ADMIN`)
ship with the default password `moonbase!` and `mustChangePassword=true` — the app
forces a password change on first sign-in.
## Deploy
The NAS appdata (`/mnt/user/appdata/moonbase`) is a **git checkout** of this repo.
Two ways to ship a change:
1. **In-app (admins):** Settings → Updates → **Pull & rebuild**. A privileged
`updater` sidecar runs `git pull && docker compose up -d --build app` and the
UI reloads on the new version.
2. **Manual:**
```sh
git push origin master
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
```
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
`.env` next to the compose file, never committed. Migrations apply automatically
on container start.
## Conventions
Every commit bumps `APP_VERSION` (`src/lib/version.ts`) + a plain-English entry in
`src/lib/changelog.ts` (written for Bonna, shown in the footer). `AuditEvent` on
every mutation. Soft-delete via `active=false`. Pure logic in `src/lib/*` with
colocated tests; API routes stay thin. Repo on Gitea: `bonna61/Moonbase`.

View File

@@ -14,4 +14,7 @@ echo "Running Prisma migrations..."
echo "Running Location-spine backfill (idempotent)..."
./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs."
echo "Running plant-type backfill (idempotent)..."
./node_modules/.bin/tsx prisma/scripts/backfill-plant-types.ts || echo " Backfill skipped/failed (non-fatal); check logs."
exec "$@"

View File

@@ -15,6 +15,7 @@
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts",
"db:backfill-plant-types": "tsx prisma/scripts/backfill-plant-types.ts",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset --force",
"db:backup": "sh scripts/db-backup.sh",

View File

@@ -0,0 +1,42 @@
-- v0.11.0 — Plant types (the "kind" record)
-- A rich-but-all-optional reference record per kind of plant, plus an optional
-- link from each planting. Seeded/linked by prisma/scripts/backfill-plant-types.ts.
-- CreateTable
CREATE TABLE "PlantType" (
"id" TEXT NOT NULL,
"commonName" TEXT NOT NULL,
"species" TEXT,
"category" "PlantCategory" NOT NULL DEFAULT 'OTHER',
"careNotes" TEXT,
"sun" TEXT,
"water" TEXT,
"bloomStart" INTEGER,
"bloomEnd" INTEGER,
"harvestStart" INTEGER,
"harvestEnd" INTEGER,
"toxicToPets" BOOLEAN,
"edibleParts" TEXT,
"spacing" TEXT,
"imageUrl" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlantType_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlantType_commonName_idx" ON "PlantType"("commonName");
CREATE INDEX "PlantType_category_idx" ON "PlantType"("category");
CREATE INDEX "PlantType_active_idx" ON "PlantType"("active");
-- AlterTable: optional link from a planting to its kind
ALTER TABLE "Plant" ADD COLUMN "plantTypeId" TEXT;
-- CreateIndex
CREATE INDEX "Plant_plantTypeId_idx" ON "Plant"("plantTypeId");
-- AddForeignKey
ALTER TABLE "Plant" ADD CONSTRAINT "Plant_plantTypeId_fkey" FOREIGN KEY ("plantTypeId") REFERENCES "PlantType"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,24 @@
-- v0.12.0 — API tokens (personal access tokens for the REST API + MCP)
-- CreateTable
CREATE TABLE "ApiToken" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"scopes" TEXT[],
"userId" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
-- AddForeignKey
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "ItemKind" AS ENUM ('DURABLE', 'CONSUMABLE');
-- CreateEnum
CREATE TYPE "ItemLogType" AS ENUM ('NOTE', 'MAINTENANCE', 'MOVED', 'PURCHASED', 'REPAIRED', 'DISPOSED');
-- AlterTable
ALTER TABLE "PlantGroup" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "itemId" TEXT;
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"kind" "ItemKind" NOT NULL DEFAULT 'DURABLE',
"quantity" DECIMAL(10,2) NOT NULL DEFAULT 1,
"unit" TEXT,
"locationId" TEXT,
"parentItemId" TEXT,
"value" DECIMAL(10,2),
"purchaseDate" TIMESTAMP(3),
"warrantyExpiry" TIMESTAMP(3),
"serial" TEXT,
"modelNumber" TEXT,
"brand" TEXT,
"barcode" TEXT,
"bestBefore" TIMESTAMP(3),
"minStock" DECIMAL(10,2),
"qrSlug" TEXT,
"imageUrl" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Label" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
CONSTRAINT "Label_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LabelOnItem" (
"itemId" TEXT NOT NULL,
"labelId" TEXT NOT NULL,
CONSTRAINT "LabelOnItem_pkey" PRIMARY KEY ("itemId","labelId")
);
-- CreateTable
CREATE TABLE "ItemAttachment" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"url" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ItemAttachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ItemLog" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"type" "ItemLogType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
"cost" DECIMAL(10,2),
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ItemLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Item_qrSlug_key" ON "Item"("qrSlug");
-- CreateIndex
CREATE INDEX "Item_locationId_idx" ON "Item"("locationId");
-- CreateIndex
CREATE INDEX "Item_parentItemId_idx" ON "Item"("parentItemId");
-- CreateIndex
CREATE INDEX "Item_kind_idx" ON "Item"("kind");
-- CreateIndex
CREATE INDEX "Item_active_idx" ON "Item"("active");
-- CreateIndex
CREATE INDEX "Item_name_idx" ON "Item"("name");
-- CreateIndex
CREATE INDEX "Item_barcode_idx" ON "Item"("barcode");
-- CreateIndex
CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name");
-- CreateIndex
CREATE INDEX "LabelOnItem_labelId_idx" ON "LabelOnItem"("labelId");
-- CreateIndex
CREATE INDEX "ItemAttachment_itemId_idx" ON "ItemAttachment"("itemId");
-- CreateIndex
CREATE INDEX "ItemLog_itemId_idx" ON "ItemLog"("itemId");
-- CreateIndex
CREATE INDEX "ItemLog_date_idx" ON "ItemLog"("date");
-- CreateIndex
CREATE INDEX "Task_itemId_idx" ON "Task"("itemId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_labelId_fkey" FOREIGN KEY ("labelId") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemAttachment" ADD CONSTRAINT "ItemAttachment_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemLog" ADD CONSTRAINT "ItemLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -31,6 +31,29 @@ model User {
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiTokens ApiToken[]
}
// ---------------------------------------------------------------------------
// API tokens — personal access tokens for the REST API + MCP server.
// Only the SHA-256 hash of the raw token is stored; the raw value (mb_…) is
// shown once at creation. `scopes` gate what a token can do (e.g. "plants:read").
// ---------------------------------------------------------------------------
model ApiToken {
id String @id @default(cuid())
name String // human label ("Home Assistant", "phone shortcut")
tokenHash String @unique // sha256(raw)
prefix String // first 8 chars of the raw token, shown in the UI
scopes String[] // e.g. ["plants:read","items:write"]; ["*"] = full
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// ---------------------------------------------------------------------------
@@ -77,6 +100,7 @@ model Location {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
items Item[]
@@index([parentId])
@@index([kind])
@@ -126,6 +150,8 @@ model Plant {
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
locationId String? // shared Location spine
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
plantTypeId String? // optional link to the "kind" reference record
plantType PlantType? @relation(fields: [plantTypeId], references: [id], onDelete: SetNull)
plantedAt DateTime? // when it went in the ground
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
notes String? // general notes about this plant
@@ -141,10 +167,42 @@ model Plant {
@@index([category])
@@index([zone])
@@index([locationId])
@@index([plantTypeId])
@@index([active])
@@index([groupId])
}
// PlantType — the "kind" of plant (e.g. "Lily of the valley"), holding general
// reusable reference info. Rich but every field optional; seeded from the
// built-in plant library (src/lib/garden/plant-db.ts). Individual Plant rows
// optionally point at their type.
model PlantType {
id String @id @default(cuid())
commonName String
species String? // scientific name
category PlantCategory @default(OTHER)
careNotes String? // general care: pruning, watering, feeding
sun String? // "Full sun", "Part shade", "Shade"
water String? // "Low", "Moderate", "High"
bloomStart Int? // month 112
bloomEnd Int? // month 112
harvestStart Int? // month 112
harvestEnd Int? // month 112
toxicToPets Boolean? // null = unknown
edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 ft", "18 in"
imageUrl String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
@@index([commonName])
@@index([category])
@@index([active])
}
enum PlantLogType {
OBSERVATION // general note — "looking healthy", "saw first flowers"
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
@@ -179,6 +237,8 @@ model Task {
category TaskCategory @default(GARDEN)
plantId String?
plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month
@@ -191,6 +251,7 @@ model Task {
@@index([nextDue])
@@index([plantId])
@@index([itemId])
@@index([active])
}
@@ -219,3 +280,118 @@ model PlantLog {
@@index([plantId])
@@index([date])
}
// ---------------------------------------------------------------------------
// Inventory — things you own. Durable items (tools, gear) and, later,
// consumables (food, supplies) share this one table, told apart by `kind`.
// Items nest: an Item lives in a Location OR inside another Item (toolbox →
// drill). Consumable-only fields (barcode, bestBefore, minStock) are unused
// by the durable UI but present so Pantry needs no schema change.
// ---------------------------------------------------------------------------
enum ItemKind {
DURABLE // tools, gear, equipment — "where is it?"
CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase)
}
model Item {
id String @id @default(cuid())
name String
description String?
kind ItemKind @default(DURABLE)
quantity Decimal @default(1) @db.Decimal(10, 2)
unit String? // "each", "lbs", "bottles"
// Where it lives — a place, or inside another item (containers are items too).
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
parentItemId String?
parentItem Item? @relation("ItemNesting", fields: [parentItemId], references: [id], onDelete: SetNull)
containedItems Item[] @relation("ItemNesting")
// Durable attributes
value Decimal? @db.Decimal(10, 2)
purchaseDate DateTime?
warrantyExpiry DateTime?
serial String?
modelNumber String?
brand String?
// Consumable attributes (Pantry phase)
barcode String?
bestBefore DateTime?
minStock Decimal? @db.Decimal(10, 2)
// Shared
qrSlug String? @unique // short slug for a printed QR label
imageUrl String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labels LabelOnItem[]
attachments ItemAttachment[]
logs ItemLog[]
tasks Task[]
@@index([locationId])
@@index([parentItemId])
@@index([kind])
@@index([active])
@@index([name])
@@index([barcode])
}
model Label {
id String @id @default(cuid())
name String @unique
color String?
items LabelOnItem[]
}
model LabelOnItem {
itemId String
labelId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([itemId, labelId])
@@index([labelId])
}
model ItemAttachment {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
kind String // "PHOTO" | "MANUAL" | "RECEIPT"
url String
name String?
createdAt DateTime @default(now())
@@index([itemId])
}
enum ItemLogType {
NOTE
MAINTENANCE
MOVED
PURCHASED
REPAIRED
DISPOSED
}
model ItemLog {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
type ItemLogType
date DateTime @default(now())
notes String?
cost Decimal? @db.Decimal(10, 2)
actorId String?
createdAt DateTime @default(now())
@@index([itemId])
@@index([date])
}

View File

@@ -0,0 +1,23 @@
/**
* v0.11.0 Plant-type backfill. Seeds the PlantType library from the built-in
* plant DB and links existing plantings to their kind. Idempotent — runs on
* deploy (docker-entrypoint.sh) and can be run manually:
*
* npx tsx prisma/scripts/backfill-plant-types.ts
*/
import { PrismaClient } from "@prisma/client";
import { syncPlantTypesFromLibrary } from "../../src/lib/garden/plant-types";
const db = new PrismaClient();
async function main() {
const r = await syncPlantTypesFromLibrary(db);
console.log(`Plant types: library has ${r.types} kinds; linked ${r.linked} planting(s).`);
}
main()
.catch((e) => {
console.error("Plant-type backfill failed:", e);
process.exit(1);
})
.finally(() => db.$disconnect());

View File

@@ -1,5 +1,6 @@
import { PrismaClient, PlantCategory } from "@prisma/client";
import { hashSync } from "bcryptjs";
import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types";
const db = new PrismaClient();
@@ -56,6 +57,25 @@ async function main() {
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
];
// Starter Location tree — a root property node with the garden areas under it.
const root = await db.location.upsert({
where: { id: "seed-loc-root" },
update: {},
create: { id: "seed-loc-root", name: "Moon Homestead", kind: "OTHER", path: "Moon Homestead" },
});
const zoneNames = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[]));
const zoneToLocId = new Map<string, string>();
for (const z of zoneNames) {
const id = `seed-loc-${z.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
await db.location.upsert({
where: { id },
update: {},
create: { id, name: z, kind: "AREA", parentId: root.id, path: `Moon Homestead ${z}` },
});
zoneToLocId.set(z, id);
}
for (const p of plants) {
await db.plant.upsert({
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
@@ -63,10 +83,15 @@ async function main() {
create: {
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
...p,
locationId: p.zone ? zoneToLocId.get(p.zone) : undefined,
},
});
}
// Seed the PlantType library and link the sample plants to their kind.
const pt = await syncPlantTypesFromLibrary(db);
console.log(`Seeded ${pt.types} plant types; linked ${pt.linked} plantings.`);
console.log("Demo seed complete.");
}

View File

@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
import { AddLogButton } from "@/components/garden/add-log-button";
@@ -16,18 +16,22 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
}
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
const [plant, allPlants, groups] = await Promise.all([
const [plant, locations, groups] = await Promise.all([
db.plant.findUnique({
where: { id: params.id },
include: { logs: { orderBy: { date: "desc" } } },
include: {
logs: { orderBy: { date: "desc" } },
location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true } },
},
}),
db.plant.findMany({ where: { active: true }, select: { zone: true } }),
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
if (!plant || !plant.active) notFound();
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
const placeName = plant.location?.name ?? plant.zone ?? null;
return (
<div className="space-y-6 max-w-2xl">
@@ -48,7 +52,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</div>
<div className="flex items-center gap-2">
<EditPlantButton plant={plant} zones={zones} groups={groups} />
<EditPlantButton plant={plant} locations={locations} groups={groups} />
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
</div>
@@ -60,10 +64,18 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<Card>
<CardContent className="pt-4 space-y-2 text-sm">
{plant.zone && (
{placeName && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" />
<span>{plant.zone}</span>
<span>{placeName}</span>
</div>
)}
{plant.plantType && (
<div className="flex items-center gap-2 text-muted-foreground">
<BookOpen className="h-4 w-4 shrink-0" />
<Link href={`/garden/types/${plant.plantType.id}`} className="hover:text-foreground underline-offset-2 hover:underline">
More about {plant.plantType.commonName}
</Link>
</div>
)}
{plant.plantedAt && (

View File

@@ -5,7 +5,7 @@ import { GardenGrid } from "@/components/garden/garden-grid";
export const metadata = { title: "Garden" };
export default async function GardenPage() {
const [plants, groups] = await Promise.all([
const [plants, groups, locations] = await Promise.all([
db.plant.findMany({
where: { active: true },
orderBy: [{ commonName: "asc" }],
@@ -13,6 +13,7 @@ export default async function GardenPage() {
_count: { select: { logs: true } },
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
group: { select: { id: true, name: true } },
location: { select: { id: true, name: true } },
},
}),
db.plantGroup.findMany({
@@ -20,10 +21,13 @@ export default async function GardenPage() {
orderBy: { name: "asc" },
include: { _count: { select: { plants: true } } },
}),
db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: { id: true, name: true },
}),
]);
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -33,7 +37,7 @@ export default async function GardenPage() {
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
</p>
</div>
<AddPlantButton groups={groups} zones={zones} />
<AddPlantButton groups={groups} locations={locations} />
</div>
<GardenGrid plants={plants} groups={groups} />

View File

@@ -0,0 +1,163 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
} from "lucide-react";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function monthRange(start: number | null, end: number | null): string | null {
if (start && end) return `${MONTHS[start]}${MONTHS[end]}`;
if (start) return `from ${MONTHS[start]}`;
if (end) return `through ${MONTHS[end]}`;
return null;
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const t = await db.plantType.findUnique({ where: { id: params.id }, select: { commonName: true } });
return { title: t?.commonName ?? "Plant type" };
}
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
const type = await db.plantType.findUnique({
where: { id: params.id },
include: {
plants: {
where: { active: true },
orderBy: { commonName: "asc" },
include: { location: { select: { name: true } } },
},
},
});
if (!type || !type.active) notFound();
const bloom = monthRange(type.bloomStart, type.bloomEnd);
const harvest = monthRange(type.harvestStart, type.harvestEnd);
const formFields: PlantTypeFields = {
id: type.id,
commonName: type.commonName,
species: type.species,
category: type.category,
careNotes: type.careNotes,
sun: type.sun,
water: type.water,
bloomStart: type.bloomStart,
bloomEnd: type.bloomEnd,
harvestStart: type.harvestStart,
harvestEnd: type.harvestEnd,
toxicToPets: type.toxicToPets,
edibleParts: type.edibleParts,
spacing: type.spacing,
notes: type.notes,
};
const facts: { icon: React.ReactNode; label: string; value: string }[] = [];
if (type.sun) facts.push({ icon: <Sun className="h-4 w-4" />, label: "Sun", value: type.sun });
if (type.water) facts.push({ icon: <Droplets className="h-4 w-4" />, label: "Water", value: type.water });
if (bloom) facts.push({ icon: <Flower2 className="h-4 w-4" />, label: "Blooms", value: bloom });
if (harvest) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Harvest", value: harvest });
if (type.edibleParts) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Edible", value: type.edibleParts });
if (type.spacing) facts.push({ icon: <Ruler className="h-4 w-4" />, label: "Spacing", value: type.spacing });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/garden/types" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{type.commonName}</h1>
{type.species && <p className="text-sm text-muted-foreground italic">{type.species}</p>}
</div>
<Badge variant="secondary" className="ml-auto shrink-0">{CATEGORY_LABELS[type.category]}</Badge>
</div>
<div className="flex items-center gap-2">
<EditPlantTypeButton plantType={formFields} />
<DeletePlantTypeButton id={type.id} name={type.commonName} />
</div>
{type.toxicToPets && (
<div className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))]">
<AlertTriangle className="h-4 w-4 shrink-0" />
Toxic to pets keep away from animals.
</div>
)}
{type.toxicToPets === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-[hsl(var(--leaf))]"></span> Safe for pets.
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{facts.map((fct, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{fct.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{fct.label}</p>
<p className="truncate">{fct.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{(type.careNotes || type.notes) && (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
{type.careNotes && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Care</p>
<p className="whitespace-pre-wrap">{type.careNotes}</p>
</div>
)}
{type.notes && (
<div className={type.careNotes ? "pt-2 border-t" : ""}>
<p className="whitespace-pre-wrap">{type.notes}</p>
</div>
)}
</CardContent>
</Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">
Your plantings {type.plants.length > 0 && <span className="text-sm text-muted-foreground font-normal">· {type.plants.length}</span>}
</h2>
{type.plants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
None planted yet this is general info you can keep regardless.
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{type.plants.map((p) => (
<Link key={p.id} href={`/garden/${p.id}`}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent transition-colors text-sm">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="font-medium">
{p.commonName}{p.variety && <span className="text-muted-foreground font-normal"> · {p.variety}</span>}
</span>
{p.location?.name && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />{p.location.name}
</span>
)}
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { BookOpen, ChevronRight, Leaf, AlertTriangle } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { AddPlantTypeButton } from "@/components/garden/plant-type-dialog";
export const metadata = { title: "Plant types" };
export default async function PlantTypesPage() {
const types = await db.plantType.findMany({
where: { active: true },
orderBy: { commonName: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
const byCategory = CATEGORY_ORDER.map((cat) => ({
category: cat,
types: types.filter((t) => t.category === cat),
})).filter((g) => g.types.length > 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Plant types</h1>
<p className="text-sm text-muted-foreground mt-0.5">
General knowledge about each kind of plant care, sun, bloom &amp; harvest, toxicity.
{" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
</p>
</div>
<AddPlantTypeButton />
</div>
{types.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plant types yet</p>
<p className="text-sm mt-1">Add one, or they&apos;ll appear here as you add plants.</p>
</div>
) : (
<div className="space-y-6">
{byCategory.map((group) => (
<div key={group.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{CATEGORY_LABELS[group.category]}
</p>
<div className="divide-y border rounded-lg overflow-hidden">
{group.types.map((t) => (
<Link key={t.id} href={`/garden/types/${t.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{t.commonName}</p>
{t.species && <p className="text-xs text-muted-foreground italic truncate">{t.species}</p>}
</div>
{t.toxicToPets && (
<Badge variant="outline" className="gap-1 text-xs text-[hsl(var(--ember))] border-[hsl(var(--ember)/.4)]">
<AlertTriangle className="h-3 w-3" /> Toxic
</Badge>
)}
{t._count.plants > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{t._count.plants} planted
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
function toDateInput(d: Date | null): string {
return d ? new Date(d).toISOString().split("T")[0] : "";
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const item = await db.item.findUnique({ where: { id: params.id }, select: { name: true } });
return { title: item?.name ?? "Item" };
}
export default async function ItemDetailPage({ params }: { params: { id: string } }) {
const [item, locations, allItems, labels] = await Promise.all([
db.item.findUnique({
where: { id: params.id },
include: {
location: { select: { id: true, name: true } },
parentItem: { select: { id: true, name: true } },
labels: { include: { label: true } },
attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" } },
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
},
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.item.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.label.findMany({ orderBy: { name: "asc" } }),
]);
if (!item || !item.active) notFound();
const wStatus = warrantyStatus(item.warrantyExpiry, new Date());
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
const editFields: ItemFields = {
id: item.id,
name: item.name,
description: item.description ?? "",
quantity: String(Number(item.quantity)),
unit: item.unit ?? "",
locationId: item.locationId ?? "",
parentItemId: item.parentItemId ?? "",
brand: item.brand ?? "",
modelNumber: item.modelNumber ?? "",
serial: item.serial ?? "",
value: item.value != null ? String(Number(item.value)) : "",
purchaseDate: toDateInput(item.purchaseDate),
warrantyExpiry: toDateInput(item.warrantyExpiry),
barcode: item.barcode ?? "",
notes: item.notes ?? "",
labelIds: item.labels.map((l) => l.labelId),
};
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
if (item.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: item.location.name });
if (item.parentItem) facts.push({ icon: <Box className="h-4 w-4" />, label: "Inside", value: <Link className="hover:underline" href={`/inventory/${item.parentItem.id}`}>{item.parentItem.name}</Link> });
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
if (item.barcode) facts.push({ icon: <Barcode className="h-4 w-4" />, label: "Barcode", value: item.barcode });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/inventory" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div>
{wStatus !== "none" && (
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} />
<AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} />
</div>
{item.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.labels.map((l) => <Badge key={l.labelId} variant="secondary">{l.label.name}</Badge>)}
</div>
)}
{photos.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{photos.map((p) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={p.id} src={p.url} alt={p.name ?? ""} className="h-28 w-full object-cover rounded-lg border" />
))}
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{item.notes && (
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
)}
{docs.length > 0 && (
<div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{docs.map((d) => (
<a key={d.id} href={d.url} target="_blank" rel="noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<FileText className="h-4 w-4 text-muted-foreground" />{d.name ?? d.kind}
<span className="ml-auto text-xs text-muted-foreground">{d.kind}</span>
</a>
))}
</div>
</div>
)}
{item.containedItems.length > 0 && (
<div className="space-y-1.5">
<h2 className="font-display text-lg font-semibold">Contains</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{item.containedItems.map((c) => (
<Link key={c.id} href={`/inventory/${c.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<Package className="h-4 w-4 text-[hsl(var(--leaf))]" />{c.name}
</Link>
))}
</div>
</div>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">History</h2>
{item.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No history yet log maintenance, a repair, or a move above.
</div>
) : (
<div className="space-y-2">
{item.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ITEM_LOG_COLORS[log.type]}`}>
{ITEM_LOG_LABELS[log.type]}
</span>
{log.cost != null && <span className="text-xs text-muted-foreground">· {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { db } from "@/lib/db";
import { AddItemButton } from "@/components/inventory/item-dialog";
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
import { totalValue } from "@/lib/inventory/value";
export const metadata = { title: "Inventory" };
export default async function InventoryPage() {
const [items, locations, labels] = await Promise.all([
db.item.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: {
location: { select: { id: true, name: true } },
parentItem: { select: { id: true, name: true } },
labels: { include: { label: true } },
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
},
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.label.findMany({ orderBy: { name: "asc" } }),
]);
// Convert Prisma Decimals to plain numbers before handing to client components.
const gridItems: GridItem[] = items.map((i) => ({
id: i.id,
name: i.name,
quantity: Number(i.quantity),
unit: i.unit,
value: i.value != null ? Number(i.value) : null,
warrantyExpiry: i.warrantyExpiry,
imageUrl: i.imageUrl,
locationId: i.locationId,
parentItemId: i.parentItemId,
location: i.location,
labels: i.labels,
_count: i._count,
}));
const total = totalValue(items.map((i) => ({
value: i.value != null ? Number(i.value) : null,
quantity: Number(i.quantity),
})));
const itemOpts = items.map((i) => ({ id: i.id, name: i.name }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Inventory</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{items.length} {items.length === 1 ? "item" : "items"}
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
</p>
</div>
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
</div>
<InventoryGrid items={gridItems} />
</div>
);
}

View File

@@ -7,6 +7,10 @@ import { Footer } from "@/components/layout/footer";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) redirect("/login");
// Force a password change before anything else (provisioned accounts ship with
// a known default + mustChangePassword=true). The target page is outside this
// layout group, so there's no redirect loop.
if (session.user.mustChangePassword) redirect("/change-password");
return (
<div className="flex h-screen flex-col">

View File

@@ -1,115 +1,14 @@
"use client";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession, isAdmin } from "@/lib/auth";
import { BackupPanel } from "@/components/settings/backup-panel";
import { useState, useRef } from "react";
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = { title: "Backup & Restore" };
export const dynamic = "force-dynamic";
export default function BackupPage() {
const [restoreStatus, setRestoreStatus] = useState<
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null
>(null);
const [restoring, setRestoring] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
if (!file.name.endsWith(".zip")) {
setRestoreStatus({ type: "error", message: "Please upload a .zip backup file." });
return;
}
if (!confirm("This will REPLACE all current data with the backup. Are you sure?")) return;
setRestoring(true);
setRestoreStatus(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/admin/restore", { method: "POST", body: form });
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Restore failed");
setRestoreStatus({ type: "success", exportedAt: json.exportedAt });
} catch (err) {
setRestoreStatus({ type: "error", message: String(err) });
} finally {
setRestoring(false);
}
}
return (
<div className="max-w-xl space-y-8">
<div>
<h1 className="text-2xl font-bold">Backup &amp; Restore</h1>
<p className="mt-1 text-sm text-muted-foreground">
Download a full backup of all Moon Base data, or restore from a previous backup.
</p>
</div>
{/* Backup */}
<section className="rounded-lg border p-6 space-y-3">
<h2 className="font-semibold text-lg">Download Backup</h2>
<p className="text-sm text-muted-foreground">
Downloads all plants, logs, tasks, and settings as a <code>.zip</code> file. Keep it somewhere safe.
</p>
<a href="/api/admin/backup" download>
<Button className="gap-2">
<Download className="h-4 w-4" />
Download backup
</Button>
</a>
</section>
{/* Restore */}
<section className="rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-lg">Restore from Backup</h2>
<p className="text-sm text-muted-foreground">
Upload a <code>.zip</code> backup file to restore. <strong>This replaces everything</strong> all current data will be overwritten.
</p>
<div
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer ${
dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"}
</p>
<input
ref={inputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{restoreStatus?.type === "success" && (
<div className="flex items-start gap-2 rounded-md bg-green-50 border border-green-200 p-3 text-sm text-green-800">
<CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
Restore complete. Data from backup exported on{" "}
<strong>{new Date(restoreStatus.exportedAt).toLocaleString()}</strong> is now live.
Reload the page to see it.
</div>
</div>
)}
{restoreStatus?.type === "error" && (
<div className="flex items-start gap-2 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div>{restoreStatus.message}</div>
</div>
)}
</section>
</div>
);
export default async function BackupPage() {
const session = await getSession();
if (!session) redirect("/login");
if (!isAdmin(session.user.role)) redirect("/dashboard");
return <BackupPanel />;
}

View File

@@ -0,0 +1,18 @@
import { ApiTokensPanel } from "@/components/settings/api-tokens-panel";
export const metadata = { title: "API tokens" };
export default function TokensPage() {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="font-display text-2xl font-semibold">API tokens</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Personal access tokens for the Moon Base API use them with the{" "}
<code className="text-xs">Authorization: Bearer </code> header.
</p>
</div>
<ApiTokensPanel />
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { compare, hash } from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const schema = z.object({
currentPassword: z.string().min(1, "Enter your current password."),
newPassword: z.string().min(8, "New password must be at least 8 characters."),
});
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input." },
{ status: 400 }
);
}
const { currentPassword, newPassword } = parsed.data;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!(await compare(currentPassword, user.passwordHash))) {
return NextResponse.json({ error: "Current password is incorrect." }, { status: 400 });
}
if (await compare(newPassword, user.passwordHash)) {
return NextResponse.json(
{ error: "New password must be different from your current one." },
{ status: 400 }
);
}
await db.user.update({
where: { id: session.user.id },
data: { passwordHash: await hash(newPassword, 10), mustChangePassword: false },
});
await db.auditEvent.create({
data: { action: "user.change_password", actorId: session.user.id, entityId: session.user.id },
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
// DELETE /api/account/tokens/:id — revoke one of your own tokens.
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const token = await db.apiToken.findUnique({ where: { id: params.id } });
if (!token || token.userId !== session.user.id)
return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.apiToken.update({ where: { id: params.id }, data: { revokedAt: new Date() } });
await db.auditEvent.create({
data: {
action: "api_token.revoke",
entityId: params.id,
actorId: session.user.id,
payload: { name: token.name },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import {
generateRawToken, hashToken, tokenPrefix, KNOWN_SCOPES,
} from "@/lib/api/authenticate";
const scopeSchema = z.union([z.literal("*"), z.enum(KNOWN_SCOPES)]);
const createSchema = z.object({
name: z.string().min(1),
scopes: z.array(scopeSchema).min(1),
expiresAt: z.string().optional(),
});
// GET /api/account/tokens — list the current user's tokens (no secrets).
export async function GET() {
try {
const session = await requireAuth();
const tokens = await db.apiToken.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
select: {
id: true, name: true, prefix: true, scopes: true,
lastUsedAt: true, expiresAt: true, revokedAt: true, createdAt: true,
},
});
return NextResponse.json(tokens);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/account/tokens — create a token. The raw value is returned ONCE.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const raw = generateRawToken();
const token = await db.apiToken.create({
data: {
name: data.name.trim(),
tokenHash: hashToken(raw),
prefix: tokenPrefix(raw),
scopes: data.scopes,
userId: session.user.id,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
},
select: { id: true, name: true, prefix: true, scopes: true, expiresAt: true, createdAt: true },
});
await db.auditEvent.create({
data: {
action: "api_token.create",
entityId: token.id,
actorId: session.user.id,
payload: { name: token.name, scopes: token.scopes },
},
});
// `token` (raw) is shown to the user once and never stored in plaintext.
return NextResponse.json({ ...token, token: raw }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -10,31 +10,48 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] =
await Promise.all([
db.location.findMany(),
db.plantType.findMany(),
db.user.findMany(),
db.apiToken.findMany(),
db.plantGroup.findMany(),
db.plant.findMany(),
db.plantLog.findMany(),
db.task.findMany(),
db.taskCompletion.findMany(),
db.label.findMany(),
db.item.findMany(),
db.labelOnItem.findMany(),
db.itemAttachment.findMany(),
db.itemLog.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]);
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"],
};
const zip = zipSync({
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
"plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)),
"users.json": strToU8(JSON.stringify(users, null, 2)),
"apiTokens.json": strToU8(JSON.stringify(apiTokens, null, 2)),
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
"labels.json": strToU8(JSON.stringify(labels, null, 2)),
"items.json": strToU8(JSON.stringify(items, null, 2)),
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
});

View File

@@ -1,57 +1,129 @@
import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate";
import { z } from "zod";
import { UserRole } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function POST(req: Request) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
export const dynamic = "force-dynamic";
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
// users is the security boundary, so it's validated strictly (known fields only,
// role constrained to the enum). Other tables are app-owned data — we only check
// they're arrays; Prisma rejects any unknown columns on insert.
class BadBackup extends Error {}
const userSchema = z
.object({
id: z.string(),
email: z.string(),
username: z.string().nullable().optional(),
name: z.string(),
passwordHash: z.string(),
role: z.nativeEnum(UserRole).optional(),
active: z.boolean().optional(),
mustChangePassword: z.boolean().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
.strict();
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const buf = await file.arrayBuffer();
const files = unzipSync(new Uint8Array(buf));
let files: Record<string, Uint8Array>;
try {
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
} catch {
throw new BadBackup("not a valid zip");
}
const parse = (name: string) => {
if (!files[name]) throw new Error(`Missing ${name} in zip`);
return JSON.parse(strFromU8(files[name]));
const parse = (name: string): unknown => {
if (!files[name]) throw new BadBackup(`missing ${name}`);
try {
return JSON.parse(strFromU8(files[name]));
} catch {
throw new BadBackup(`invalid JSON in ${name}`);
}
};
const asArray = (name: string): unknown[] => {
const v = parse(name);
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
return v;
};
const manifest = parse("manifest.json");
if (manifest.version !== 1) {
const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
if (manifest?.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
}
const users = parse("users.json");
const plantGroups = parse("plantGroups.json");
const plants = parse("plants.json");
const plantLogs = parse("plantLogs.json");
const tasks = parse("tasks.json");
const taskCompletions = parse("taskCompletions.json");
const auditEvents = parse("auditEvents.json");
// Older backups won't have newer tables — tolerate their absence.
const locations = files["locations.json"] ? asArray("locations.json") : [];
const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
const users = z.array(userSchema).parse(asArray("users.json"));
const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
const plantGroups = asArray("plantGroups.json");
const plants = asArray("plants.json");
const plantLogs = asArray("plantLogs.json");
const tasks = asArray("tasks.json");
const taskCompletions = asArray("taskCompletions.json");
const labels = files["labels.json"] ? asArray("labels.json") : [];
const items = files["items.json"] ? asArray("items.json") : [];
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order
// Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => {
await tx.taskCompletion.deleteMany();
await tx.plantLog.deleteMany();
await tx.itemLog.deleteMany();
await tx.itemAttachment.deleteMany();
await tx.labelOnItem.deleteMany();
await tx.task.deleteMany();
await tx.item.deleteMany();
await tx.label.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
await tx.plantType.deleteMany();
await tx.location.deleteMany();
await tx.apiToken.deleteMany();
await tx.auditEvent.deleteMany();
await tx.user.deleteMany();
if (users.length) await tx.user.createMany({ data: users });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
if (plants.length) await tx.plant.createMany({ data: plants });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
if (tasks.length) await tx.task.createMany({ data: tasks });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
// Location self-references parentId; a single createMany is one statement,
// so the FK is validated only after every row exists — order-independent.
if (locations.length) await tx.location.createMany({ data: locations as never });
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
if (plants.length) await tx.plant.createMany({ data: plants as never });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
if (labels.length) await tx.label.createMany({ data: labels as never });
// Item self-references parentItemId; one createMany statement validates the
// FK only after all rows exist — order-independent.
if (items.length) await tx.item.createMany({ data: items as never });
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
});
await db.auditEvent.create({
@@ -64,10 +136,13 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (err instanceof BadBackup || err instanceof z.ZodError) {
return NextResponse.json(
{ error: "The backup file is invalid or corrupted." },
{ status: 400 }
);
}
console.error(err);
return NextResponse.json({ error: String(err) }, { status: 500 });
console.error("restore failed:", err);
return NextResponse.json({ error: "Restore failed." }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { descendantIds } from "@/lib/locations/tree";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
// POST /api/locations/[id]/log — append a log to every active plant in this
// location and its sub-locations (e.g. "watered the whole back yard").
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Location not found" }, { status: 404 });
// This location + everything nested under it.
const allRows = await db.location.findMany({ select: { id: true, parentId: true, name: true } });
const locationIds = descendantIds(params.id, allRows, true);
const plants = await db.plant.findMany({
where: { locationId: { in: locationIds }, active: true },
select: { id: true },
});
if (plants.length === 0)
return NextResponse.json({ error: "No plants found in this location" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
const logs = await db.$transaction(
plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
}),
),
);
await db.auditEvent.create({
data: {
action: "location.log",
entityId: params.id,
actorId: session.user.id,
payload: { location: location.name, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { LocationKind } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
import { recomputeAllPaths, wouldCreateCycle } from "@/lib/locations/db";
const updateSchema = z.object({
name: z.string().min(1).optional(),
kind: z.nativeEnum(LocationKind).optional(),
parentId: z.string().nullable().optional(), // null → move to top level
notes: z.string().nullable().optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
const data = updateSchema.parse(await req.json());
// Guard against cycles when re-parenting.
if (data.parentId) {
if (await wouldCreateCycle(params.id, data.parentId))
return NextResponse.json(
{ error: "A location can't be moved under itself or its own descendant" },
{ status: 400 },
);
const parent = await db.location.findUnique({ where: { id: data.parentId } });
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
}
const updated = await db.$transaction(async (tx) => {
const u = await tx.location.update({
where: { id: params.id },
data: {
...(data.name !== undefined ? { name: data.name.trim() } : {}),
...(data.kind !== undefined ? { kind: data.kind } : {}),
...(data.parentId !== undefined ? { parentId: data.parentId } : {}),
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
},
});
await recomputeAllPaths(tx as unknown as typeof db);
await tx.auditEvent.create({
data: {
action: "location.update",
entityId: params.id,
actorId: session.user.id,
payload: { name: u.name },
},
});
return u;
});
return NextResponse.json(updated);
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
// Don't orphan live records — require the location be emptied first.
const [plantCount, childCount] = await Promise.all([
db.plant.count({ where: { locationId: params.id, active: true } }),
db.location.count({ where: { parentId: params.id, active: true } }),
]);
if (plantCount > 0 || childCount > 0)
return NextResponse.json(
{ error: "Move the plants and sub-locations out of this location first" },
{ status: 400 },
);
await db.location.update({ where: { id: params.id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "location.delete",
entityId: params.id,
actorId: session.user.id,
payload: { name: location.name },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { LocationKind } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { recomputeAllPaths } from "@/lib/locations/db";
const createSchema = z.object({
name: z.string().min(1),
kind: z.nativeEnum(LocationKind).optional(),
parentId: z.string().optional(),
notes: z.string().optional(),
});
// GET /api/locations — flat list of active locations (client builds the tree).
export async function GET() {
try {
await requireAuth();
const locations = await db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
return NextResponse.json(locations);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/locations — create a location node.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const name = data.name.trim();
if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 });
// Validate parent exists (if given).
if (data.parentId) {
const parent = await db.location.findUnique({ where: { id: data.parentId } });
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
}
const location = await db.$transaction(async (tx) => {
const created = await tx.location.create({
data: {
name,
kind: data.kind ?? LocationKind.AREA,
parentId: data.parentId || null,
notes: data.notes?.trim() || null,
},
});
await recomputeAllPaths(tx as unknown as typeof db);
await tx.auditEvent.create({
data: {
action: "location.create",
entityId: created.id,
actorId: session.user.id,
payload: { name: created.name, kind: created.kind, parentId: created.parentId },
},
});
return created;
});
return NextResponse.json(location, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { PlantCategory, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
const month = z.coerce.number().int().min(1).max(12).nullable();
// All optional — a PATCH only touches the fields it sends. Empty string clears
// a text field; null clears a numeric/boolean one.
const updateSchema = z.object({
commonName: z.string().min(1).optional(),
species: z.string().optional(),
category: z.nativeEnum(PlantCategory).optional(),
careNotes: z.string().optional(),
sun: z.string().optional(),
water: z.string().optional(),
bloomStart: month.optional(),
bloomEnd: month.optional(),
harvestStart: month.optional(),
harvestEnd: month.optional(),
toxicToPets: z.boolean().nullable().optional(),
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
notes: z.string().optional(),
});
const TEXT_FIELDS = [
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
] as const;
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const existing = await db.plantType.findUnique({ where: { id: params.id } });
if (!existing || !existing.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json();
const data = updateSchema.parse(body);
const update: Prisma.PlantTypeUpdateInput = {};
if (data.commonName !== undefined) update.commonName = data.commonName.trim();
if (data.category !== undefined) update.category = data.category;
if (data.toxicToPets !== undefined) update.toxicToPets = data.toxicToPets;
for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
if (data[k] !== undefined) update[k] = data[k];
}
for (const k of TEXT_FIELDS) {
if (data[k] !== undefined) update[k] = data[k]?.trim() || null;
}
const updated = await db.plantType.update({ where: { id: params.id }, data: update });
await db.auditEvent.create({
data: {
action: "plant_type.update",
entityId: params.id,
actorId: session.user.id,
payload: { commonName: updated.commonName },
},
});
return NextResponse.json(updated);
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const existing = await db.plantType.findUnique({ where: { id: params.id } });
if (!existing || !existing.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
// Soft-delete; plantings keep their plantTypeId (the row just goes inactive).
await db.plantType.update({ where: { id: params.id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "plant_type.delete",
entityId: params.id,
actorId: session.user.id,
payload: { commonName: existing.commonName },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { PlantCategory } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const month = z.coerce.number().int().min(1).max(12);
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
category: z.nativeEnum(PlantCategory).optional(),
careNotes: z.string().optional(),
sun: z.string().optional(),
water: z.string().optional(),
bloomStart: month.optional(),
bloomEnd: month.optional(),
harvestStart: month.optional(),
harvestEnd: month.optional(),
toxicToPets: z.boolean().optional(),
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
notes: z.string().optional(),
});
// GET /api/plant-types — list active kinds with a planting count.
export async function GET() {
try {
await requireAuth();
const types = await db.plantType.findMany({
where: { active: true },
orderBy: { commonName: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
return NextResponse.json(types);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/plant-types — create a kind.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const commonName = data.commonName.trim();
if (!commonName) return NextResponse.json({ error: "Name is required" }, { status: 400 });
const created = await db.plantType.create({
data: {
commonName,
species: data.species?.trim() || null,
category: data.category ?? PlantCategory.OTHER,
careNotes: data.careNotes?.trim() || null,
sun: data.sun?.trim() || null,
water: data.water?.trim() || null,
bloomStart: data.bloomStart ?? null,
bloomEnd: data.bloomEnd ?? null,
harvestStart: data.harvestStart ?? null,
harvestEnd: data.harvestEnd ?? null,
toxicToPets: data.toxicToPets ?? null,
edibleParts: data.edibleParts?.trim() || null,
spacing: data.spacing?.trim() || null,
imageUrl: data.imageUrl?.trim() || null,
notes: data.notes?.trim() || null,
},
});
await db.auditEvent.create({
data: {
action: "plant_type.create",
entityId: created.id,
actorId: session.user.id,
payload: { commonName: created.commonName, category: created.category },
},
});
return NextResponse.json(created, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -10,12 +10,23 @@ const updateSchema = z.object({
variety: z.string().optional(),
category: z.string().min(1),
zone: z.string().optional(),
locationId: z.string().nullable().optional(),
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().nullable().optional(),
});
// Dual-write helper (Release A): keep the legacy `zone` string in sync with the
// chosen location's name until Release B drops the column.
async function resolveZone(locationId?: string | null, zone?: string): Promise<string | null> {
if (locationId) {
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
if (loc) return loc.name;
}
return zone?.trim() || null;
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
@@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
? await fetchPlantImageUrl(species, commonName)
: undefined;
const zone = await resolveZone(data.locationId, data.zone);
const updated = await db.plant.update({
where: { id: params.id },
data: {
@@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
species,
variety: data.variety?.trim() || null,
category: data.category as PlantCategory,
zone: data.zone?.trim() || null,
zone, // dual-write: kept in sync with location.name until Release B
locationId: data.locationId ?? null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,

View File

@@ -10,12 +10,40 @@ const createSchema = z.object({
variety: z.string().optional(),
category: z.string().min(1),
zone: z.string().optional(),
locationId: z.string().optional(),
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().optional(),
});
// Dual-write helper (Release A): resolve a locationId to its name so the legacy
// `zone` column stays populated until Release B drops it. Returns the zone
// string to stamp, falling back to the freeform zone when no location is given.
async function resolveZone(locationId?: string, zone?: string): Promise<string | null> {
if (locationId) {
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
if (loc) return loc.name;
}
return zone?.trim() || null;
}
// Link a new planting to its "kind" record — by species first, else common name.
async function resolvePlantTypeId(species: string | null, commonName: string): Promise<string | null> {
if (species) {
const bySpecies = await db.plantType.findFirst({
where: { active: true, species: { equals: species, mode: "insensitive" } },
select: { id: true },
});
if (bySpecies) return bySpecies.id;
}
const byCommon = await db.plantType.findFirst({
where: { active: true, commonName: { equals: commonName, mode: "insensitive" } },
select: { id: true },
});
return byCommon?.id ?? null;
}
export async function POST(req: Request) {
try {
const session = await requireAuth();
@@ -25,6 +53,8 @@ export async function POST(req: Request) {
const species = data.species?.trim() || null;
const commonName = data.commonName.trim();
const imageUrl = await fetchPlantImageUrl(species, commonName);
const zone = await resolveZone(data.locationId, data.zone);
const plantTypeId = await resolvePlantTypeId(species, commonName);
const plant = await db.plant.create({
data: {
@@ -32,7 +62,9 @@ export async function POST(req: Request) {
species,
variety: data.variety?.trim() || null,
category: data.category as PlantCategory,
zone: data.zone?.trim() || null,
zone, // dual-write: kept in sync with location.name until Release B
locationId: data.locationId || null,
plantTypeId,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,

View File

@@ -0,0 +1,49 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authenticate";
export const dynamic = "force-dynamic";
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]);
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
// under public/uploads/items (a persistent Docker volume), same as suggestions.
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const item = await db.item.findUnique({ where: { id: params.id }, select: { active: true, imageUrl: true } });
if (!item || !item.active) throw new ApiError(404, "Item not found");
const form = await req.formData();
const file = form.get("file") as File | null;
const kind = String(form.get("kind") ?? "PHOTO").toUpperCase();
if (!file) throw new ApiError(400, "No file uploaded");
if (!KINDS.has(kind)) throw new ApiError(400, "Invalid attachment kind");
const safe = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const dir = path.join(process.cwd(), "public", "uploads", "items");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, safe), Buffer.from(await file.arrayBuffer()));
const base = (process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXTAUTH_URL || "").replace(/\/+$/, "");
const url = base ? `${base}/uploads/items/${safe}` : `/uploads/items/${safe}`;
const attachment = await db.itemAttachment.create({
data: { itemId: params.id, kind, url, name: file.name },
});
// First photo becomes the item's thumbnail.
if (kind === "PHOTO" && !item.imageUrl) {
await db.item.update({ where: { id: params.id }, data: { imageUrl: url } });
}
await db.auditEvent.create({
data: { action: "item.attachment.add", entityId: params.id, actorId: ctx.userId, payload: { kind, via: ctx.via } },
});
return NextResponse.json(attachment, { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { itemLogInput } from "@/lib/api/schemas";
import { addItemLog } from "@/lib/services/items";
// POST /api/v1/items/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemLogInput.parse(await req.json());
return NextResponse.json(await addItemLog(ctx, params.id, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { itemUpdateInput } from "@/lib/api/schemas";
import { getItem, updateItem, deleteItem } from "@/lib/services/items";
// GET /api/v1/items/:id
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:read");
return NextResponse.json(await getItem(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}
// PATCH /api/v1/items/:id
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemUpdateInput.parse(await req.json());
return NextResponse.json(await updateItem(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
// DELETE /api/v1/items/:id (soft-delete; admin only — enforced in the service)
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
await deleteItem(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { itemCreateInput } from "@/lib/api/schemas";
import { listItems, createItem } from "@/lib/services/items";
// GET /api/v1/items?q=&locationId=&kind=DURABLE
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:read");
const sp = new URL(req.url).searchParams;
const kindParam = sp.get("kind");
const kind = kindParam === "DURABLE" || kindParam === "CONSUMABLE" ? kindParam : undefined;
return NextResponse.json(
await listItems(ctx, {
q: sp.get("q") ?? undefined,
locationId: sp.get("locationId") ?? undefined,
kind,
}),
);
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/items
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemCreateInput.parse(await req.json());
return NextResponse.json(await createItem(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
const createSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
});
// GET /api/v1/labels
export async function GET(req: Request) {
try {
await authenticateRequest(req, "items:read");
return NextResponse.json(await db.label.findMany({ orderBy: { name: "asc" } }));
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/labels — create (or return existing by name).
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const data = createSchema.parse(await req.json());
const name = data.name.trim();
const existing = await db.label.findUnique({ where: { name } });
if (existing) return NextResponse.json(existing);
const label = await db.label.create({ data: { name, color: data.color?.trim() || null } });
await db.auditEvent.create({
data: { action: "label.create", entityId: label.id, actorId: ctx.userId, payload: { name } },
});
return NextResponse.json(label, { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listLocations } from "@/lib/services/locations";
// GET /api/v1/locations
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "locations:read");
return NextResponse.json(await listLocations(ctx));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listPlantTypes } from "@/lib/services/plant-types";
// GET /api/v1/plant-types?q=lily
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "plant-types:read");
const q = new URL(req.url).searchParams.get("q") ?? undefined;
return NextResponse.json(await listPlantTypes(ctx, { q }));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { plantLogInput } from "@/lib/api/schemas";
import { addPlantLog } from "@/lib/services/plants";
// POST /api/v1/plants/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "plants:write");
const input = plantLogInput.parse(await req.json());
const log = await addPlantLog(ctx, params.id, input);
return NextResponse.json(log, { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { getPlant } from "@/lib/services/plants";
// GET /api/v1/plants/:id
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "plants:read");
return NextResponse.json(await getPlant(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listPlants } from "@/lib/services/plants";
// GET /api/v1/plants?q=apple
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "plants:read");
const q = new URL(req.url).searchParams.get("q") ?? undefined;
return NextResponse.json(await listPlants(ctx, { q }));
} catch (err) {
return handleApiError(err);
}
}

22
src/app/api/v1/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
// GET /api/v1 — whoami + endpoint discovery. Handy for testing a token.
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req);
return NextResponse.json({
ok: true,
via: ctx.via,
scopes: ctx.scopes,
endpoints: {
plants: "GET /api/v1/plants?q=, GET /api/v1/plants/:id, POST /api/v1/plants/:id/logs",
plantTypes: "GET /api/v1/plant-types?q=",
locations: "GET /api/v1/locations",
tasks: "GET /api/v1/tasks, POST /api/v1/tasks, POST /api/v1/tasks/:id/complete",
},
});
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { taskCompleteInput } from "@/lib/api/schemas";
import { completeTask } from "@/lib/services/tasks";
// POST /api/v1/tasks/:id/complete
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "tasks:write");
const input = taskCompleteInput.parse(await req.json().catch(() => ({})));
return NextResponse.json(await completeTask(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { taskCreateInput } from "@/lib/api/schemas";
import { listTasks, createTask } from "@/lib/services/tasks";
// GET /api/v1/tasks
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "tasks:read");
return NextResponse.json(await listTasks(ctx));
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/tasks
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "tasks:write");
const input = taskCreateInput.parse(await req.json());
return NextResponse.json(await createTask(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { ChangePasswordForm } from "@/components/account/change-password-form";
export const metadata: Metadata = { title: "Change password" };
export const dynamic = "force-dynamic";
export default async function ChangePasswordPage() {
const session = await getSession();
if (!session) redirect("/login");
return <ChangePasswordForm forced={session.user.mustChangePassword} />;
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Leaf } from "lucide-react";
export function ChangePasswordForm({ forced }: { forced: boolean }) {
const router = useRouter();
const { update } = useSession();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (next !== confirm) {
setError("The new passwords don't match.");
return;
}
setBusy(true);
try {
const res = await fetch("/api/account/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json.error ?? "Couldn't change your password.");
setBusy(false);
return;
}
// Refresh the session token so mustChangePassword clears, then continue.
await update();
router.push("/dashboard");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-6 text-center">
<div className="h-14 w-14 rounded-full bg-[hsl(var(--canopy))] flex items-center justify-center mb-3">
<Leaf className="h-7 w-7 text-[hsl(var(--canopy-foreground))]" />
</div>
<h1 className="font-display text-3xl text-foreground tracking-tight">
Choose a password
</h1>
<p className="text-xs text-muted-foreground mt-1">
{forced
? "Before you continue, please set your own password."
: "Update your Moon Base password."}
</p>
</div>
<Card>
<CardContent className="pt-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="current">Current password</Label>
<Input
id="current"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="next">New password</Label>
<Input
id="next"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm new password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-[hsl(var(--ember))]">{error}</p>}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? "Saving…" : "Save password"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
type Group = { id: string; name: string };
type LocationOption = { id: string; name: string };
const BLANK = {
commonName: "",
@@ -33,12 +34,19 @@ const BLANK = {
newGroupName: "",
};
export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) {
export function AddPlantButton({
groups = [],
locations = [],
}: {
groups?: Group[];
locations?: LocationOption[];
}) {
const [open, setOpen] = useState(false);
const [fields, setFields] = useState(BLANK);
const [selectedZones, setSelectedZones] = useState<string[]>([]);
const [newZoneName, setNewZoneName] = useState("");
const [addingZone, setAddingZone] = useState(false);
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([]);
const [newLocationName, setNewLocationName] = useState("");
const [addingLocation, setAddingLocation] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@@ -47,25 +55,46 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
const router = useRouter();
const { toast } = useToast();
// All zones including newly typed ones not yet in the DB
const allZones = Array.from(new Set([...zones, ...selectedZones]));
function set(key: keyof typeof BLANK, value: string) {
setFields((f) => ({ ...f, [key]: value }));
}
function toggleZone(zone: string) {
setSelectedZones((prev) =>
prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone]
function nameFor(id: string) {
return locationOptions.find((l) => l.id === id)?.name ?? "Location";
}
function toggleLocation(id: string) {
setSelectedLocationIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}
function addNewZone() {
const name = newZoneName.trim();
// Persist new locations to the shared spine immediately so the plant gets a
// real locationId (the freeform "zone" string is dual-written server-side).
async function addNewLocation() {
const name = newLocationName.trim();
if (!name) return;
if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]);
setNewZoneName("");
setAddingZone(false);
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
if (existing) {
if (!selectedLocationIds.includes(existing.id)) toggleLocation(existing.id);
setNewLocationName("");
setAddingLocation(false);
return;
}
const res = await fetch("/api/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
return;
}
const loc = await res.json();
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
setSelectedLocationIds((prev) => [...prev, loc.id]);
setNewLocationName("");
setAddingLocation(false);
}
function onNameChange(value: string) {
@@ -90,9 +119,9 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
function handleClose() {
setOpen(false);
setFields(BLANK);
setSelectedZones([]);
setNewZoneName("");
setAddingZone(false);
setSelectedLocationIds([]);
setNewLocationName("");
setAddingLocation(false);
setError("");
setSuggestions([]);
setShowSuggestions(false);
@@ -130,14 +159,14 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
groupId,
};
// Create one plant per selected zone (or one with no zone if none selected).
const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined];
// Create one plant per selected location (or one with no location if none).
const locsToCreate = selectedLocationIds.length > 0 ? selectedLocationIds : [undefined];
const results = await Promise.all(
zonesToCreate.map((zone) =>
locsToCreate.map((locationId) =>
fetch("/api/plants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...base, zone }),
body: JSON.stringify({ ...base, locationId }),
})
)
);
@@ -149,7 +178,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
return;
}
const count = zonesToCreate.length;
const count = locsToCreate.length;
toast({
title: "Plant added!",
description: count > 1
@@ -254,23 +283,23 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
/>
</div>
{/* Multi-location picker */}
{/* Multi-location picker (shared Location spine) */}
<div className="col-span-2 space-y-1.5">
<Label>
Location(s) on your property
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
</Label>
{/* Selected zones as chips */}
{selectedZones.length > 0 && (
{/* Selected locations as chips */}
{selectedLocationIds.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedZones.map((z) => (
<Badge key={z} variant="secondary" className="gap-1 pr-1">
{selectedLocationIds.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
<MapPin className="h-3 w-3" />
{z}
{nameFor(id)}
<button
type="button"
onClick={() => toggleZone(z)}
onClick={() => toggleLocation(id)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
@@ -280,45 +309,45 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
</div>
)}
{/* Existing zones as checkboxes */}
{allZones.length > 0 && (
{/* Existing locations as checkboxes */}
{locationOptions.length > 0 && (
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
{allZones.map((z) => (
{locationOptions.map((loc) => (
<label
key={z}
key={loc.id}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
>
<input
type="checkbox"
checked={selectedZones.includes(z)}
onChange={() => toggleZone(z)}
checked={selectedLocationIds.includes(loc.id)}
onChange={() => toggleLocation(loc.id)}
className="accent-[hsl(var(--leaf))]"
/>
{z}
{loc.name}
</label>
))}
</div>
)}
{/* Add new zone */}
{addingZone ? (
{/* Add new location */}
{addingLocation ? (
<div className="flex gap-2 mt-1">
<Input
placeholder="e.g. Front bed, Back fence guild…"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
value={newLocationName}
onChange={(e) => setNewLocationName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
autoFocus
/>
<Button type="button" size="sm" onClick={addNewZone}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}>
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingLocation(false); setNewLocationName(""); }}>
Cancel
</Button>
</div>
) : (
<button
type="button"
onClick={() => setAddingZone(true)}
onClick={() => setAddingLocation(true)}
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
>
+ Add new location
@@ -398,7 +427,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={busy}>
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"}
{busy ? "Saving…" : selectedLocationIds.length > 1 ? `Add to ${selectedLocationIds.length} locations` : "Add plant"}
</Button>
</DialogFooter>
</form>

View File

@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Trash2 } from "lucide-react";
export function DeletePlantTypeButton({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleDelete() {
setBusy(true);
const res = await fetch(`/api/plant-types/${id}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
toast({ title: "Error", description: "Could not delete.", variant: "destructive" });
return;
}
toast({ title: "Deleted", description: `"${name}" removed from your plant types.` });
setOpen(false);
router.push("/garden/types");
router.refresh();
}
return (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>
This removes the plant-type reference. Your individual plantings stay put
they just won&apos;t be linked to a type anymore.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -20,12 +20,14 @@ import { useToast } from "@/hooks/use-toast";
import { Pencil } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
type LocationOption = { id: string; name: string };
const schema = z.object({
commonName: z.string().min(1, "Name is required"),
species: z.string().optional(),
variety: z.string().optional(),
category: z.nativeEnum(PlantCategory),
zone: z.string().optional(),
locationId: z.string().optional(),
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
@@ -37,10 +39,19 @@ function toDateInput(d: Date | null): string {
return new Date(d).toISOString().split("T")[0];
}
export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) {
export function EditPlantButton({
plant,
locations = [],
groups = [],
}: {
plant: Plant;
locations?: LocationOption[];
groups?: { id: string; name: string }[];
}) {
const [open, setOpen] = useState(false);
const [creatingZone, setCreatingZone] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
const [creatingLocation, setCreatingLocation] = useState(false);
const [newLocationName, setNewLocationName] = useState("");
const router = useRouter();
const { toast } = useToast();
@@ -51,19 +62,44 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
species: plant.species ?? "",
variety: plant.variety ?? "",
category: plant.category,
zone: plant.zone ?? "",
locationId: plant.locationId ?? "",
plantedAt: toDateInput(plant.plantedAt),
source: plant.source ?? "",
notes: plant.notes ?? "",
},
});
async function addNewLocation() {
const name = newLocationName.trim();
if (!name) return;
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
if (existing) {
form.setValue("locationId", existing.id);
setNewLocationName("");
setCreatingLocation(false);
return;
}
const res = await fetch("/api/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
return;
}
const loc = await res.json();
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
form.setValue("locationId", loc.id);
setNewLocationName("");
setCreatingLocation(false);
}
async function onSubmit(values: FormValues) {
if (newZoneName.trim()) values.zone = newZoneName.trim();
const res = await fetch(`/api/plants/${plant.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
body: JSON.stringify({ ...values, locationId: values.locationId || null }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -127,12 +163,12 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
<div className="col-span-2 space-y-1.5">
<Label>Location on your property</Label>
{!creatingZone ? (
{!creatingLocation ? (
<Select
defaultValue={plant.zone ?? ""}
defaultValue={plant.locationId ?? ""}
onValueChange={(v) => {
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
else { form.setValue("zone", v); setNewZoneName(""); }
if (v === "__new__") { setCreatingLocation(true); form.setValue("locationId", ""); }
else { form.setValue("locationId", v); setNewLocationName(""); }
}}
>
<SelectTrigger>
@@ -140,8 +176,8 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
</SelectTrigger>
<SelectContent>
<SelectItem value="">No location</SelectItem>
{zones.map((z) => (
<SelectItem key={z} value={z}>{z}</SelectItem>
{locationOptions.map((loc) => (
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))}
<SelectItem value="__new__">+ Add new location</SelectItem>
</SelectContent>
@@ -150,11 +186,13 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
<div className="flex gap-2">
<Input
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
value={newLocationName}
onChange={(e) => setNewLocationName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
autoFocus
/>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}>
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingLocation(false); setNewLocationName(""); }}>
Cancel
</Button>
</div>

View File

@@ -9,7 +9,7 @@ import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lu
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { GroupLogButton } from "@/components/garden/group-log-button";
import { ZoneLogButton } from "@/components/garden/zone-log-button";
import { LocationLogButton } from "@/components/garden/location-log-button";
type Plant = {
id: string;
@@ -18,6 +18,8 @@ type Plant = {
species: string | null;
category: string;
zone: string | null;
locationId: string | null;
location: { id: string; name: string } | null;
plantedAt: Date | null;
imageUrl: string | null;
group: { id: string; name: string } | null;
@@ -25,6 +27,11 @@ type Plant = {
logs: { date: Date; type: string }[];
};
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
function placeName(p: Plant): string | null {
return p.location?.name ?? p.zone ?? null;
}
type Group = {
id: string;
name: string;
@@ -174,7 +181,7 @@ function VarietyRow({ plants, expanded, toggle }: {
{plants.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs gap-1">
<MapPin className="h-2.5 w-2.5" />
{p.zone ?? "No location"}
{placeName(p) ?? "No location"}
</Badge>
))}
</div>
@@ -196,46 +203,58 @@ function LocationView({ plants, expanded, toggle }: {
expanded: Set<string>;
toggle: (id: string) => void;
}) {
const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort();
const noZone = plants.filter((p) => !p.zone);
// Group by the shared Location (id). Plants not yet on the spine but with a
// legacy zone string still group by that name so nothing disappears mid-migration.
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
const byKey = new Map<string, Place>();
const noPlace: Plant[] = [];
for (const p of plants) {
const name = placeName(p);
if (!name) { noPlace.push(p); continue; }
const key = p.locationId ?? `zone:${name}`;
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
byKey.get(key)!.plants.push(p);
}
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="space-y-6">
{zones.map((zone) => {
const zonePlants = plants.filter((p) => p.zone === zone);
const isOpen = expanded.has(zone);
{places.map((place) => {
const isOpen = expanded.has(place.key);
return (
<div key={zone} className="space-y-2">
<div key={place.key} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(zone)}
onClick={() => toggle(place.key)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{zone}</span>
<span className="text-sm text-muted-foreground">· {zonePlants.length} {zonePlants.length === 1 ? "plant" : "plants"}</span>
<span className="font-display font-semibold">{place.name}</span>
<span className="text-sm text-muted-foreground">· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}</span>
</button>
<ZoneLogButton zone={zone} plantCount={zonePlants.length} />
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
</div>
{isOpen ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{zonePlants.map((p) => <PlantCard key={p.id} plant={p} />)}
{place.plants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
) : (
<button onClick={() => toggle(zone)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{zonePlants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
</button>
)}
</div>
);
})}
{noZone.length > 0 && (
{noPlace.length > 0 && (
<div className="space-y-2">
{zones.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
{places.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{noZone.map((p) => <PlantCard key={p.id} plant={p} />)}
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
</div>
)}
@@ -276,10 +295,10 @@ function PlantCard({ plant }: { plant: Plant }) {
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
{placeName(plant) && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</span>
<span className="truncate">{placeName(plant)}</span>
</div>
)}
{plant.plantedAt && (

View File

@@ -17,7 +17,13 @@ import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: number }) {
export function LocationLogButton({
locationId,
locationName,
}: {
locationId: string;
locationName: string;
}) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("CARE");
const [notes, setNotes] = useState("");
@@ -28,7 +34,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/zones/${encodeURIComponent(zone)}/log`, {
const res = await fetch(`/api/locations/${locationId}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined }),
@@ -39,7 +45,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
return;
}
const data = await res.json();
toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${zone}.` });
toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${locationName}.` });
setNotes("");
setOpen(false);
router.refresh();
@@ -60,7 +66,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Log for all plants in {zone}</DialogTitle>
<DialogTitle>Log for all plants in {locationName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">

View File

@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PlantCategory } from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
export type PlantTypeFields = {
id?: string;
commonName: string;
species: string | null;
category: PlantCategory;
careNotes: string | null;
sun: string | null;
water: string | null;
bloomStart: number | null;
bloomEnd: number | null;
harvestStart: number | null;
harvestEnd: number | null;
toxicToPets: boolean | null;
edibleParts: string | null;
spacing: string | null;
notes: string | null;
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const NONE = "__none__";
function blank(): PlantTypeFields {
return {
commonName: "", species: null, category: "OTHER", careNotes: null,
sun: null, water: null, bloomStart: null, bloomEnd: null,
harvestStart: null, harvestEnd: null, toxicToPets: null,
edibleParts: null, spacing: null, notes: null,
};
}
function MonthSelect({ value, onChange, placeholder }: {
value: number | null; onChange: (v: number | null) => void; placeholder: string;
}) {
return (
<Select value={value ? String(value) : NONE} onValueChange={(v) => onChange(v === NONE ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{MONTHS.map((m, i) => <SelectItem key={m} value={String(i + 1)}>{m}</SelectItem>)}
</SelectContent>
</Select>
);
}
function PlantTypeDialog({
trigger, title, initial, onSaved,
}: {
trigger: React.ReactNode;
title: string;
initial: PlantTypeFields;
onSaved: () => void;
}) {
const [open, setOpen] = useState(false);
const [f, setF] = useState<PlantTypeFields>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof PlantTypeFields>(key: K, value: PlantTypeFields[K]) {
setF((p) => ({ ...p, [key]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
setError("");
setBusy(true);
const payload: Record<string, unknown> = {
commonName: f.commonName.trim(),
species: f.species ?? "",
category: f.category,
careNotes: f.careNotes ?? "",
sun: f.sun ?? "",
water: f.water ?? "",
edibleParts: f.edibleParts ?? "",
spacing: f.spacing ?? "",
notes: f.notes ?? "",
toxicToPets: f.toxicToPets,
};
// Month fields: only send when set (empty would fail the 112 check).
for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
if (f[k] != null) payload[k] = f[k];
}
const res = await fetch(f.id ? `/api/plant-types/${f.id}` : "/api/plant-types", {
method: f.id ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
return;
}
toast({ title: f.id ? "Saved!" : "Plant type added!" });
setOpen(false);
onSaved();
router.refresh();
}
return (
<>
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Plant name *</Label>
<Input value={f.commonName} onChange={(e) => set("commonName", e.target.value)}
placeholder="Lily of the valley" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Scientific name</Label>
<Input className="italic" value={f.species ?? ""} onChange={(e) => set("species", e.target.value)}
placeholder="Convallaria majalis" />
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<Select value={f.category} onValueChange={(v) => set("category", v as PlantCategory)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORY_ORDER.map((c) => <SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Sun</Label>
<Select value={f.sun ?? NONE} onValueChange={(v) => set("sun", v === NONE ? null : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
<SelectItem value="Full sun">Full sun</SelectItem>
<SelectItem value="Part shade">Part shade</SelectItem>
<SelectItem value="Shade">Shade</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Water</Label>
<Select value={f.water ?? NONE} onValueChange={(v) => set("water", v === NONE ? null : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Moderate">Moderate</SelectItem>
<SelectItem value="High">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bloom window</Label>
<div className="flex items-center gap-1.5">
<MonthSelect value={f.bloomStart} onChange={(v) => set("bloomStart", v)} placeholder="From" />
<span className="text-muted-foreground text-xs">to</span>
<MonthSelect value={f.bloomEnd} onChange={(v) => set("bloomEnd", v)} placeholder="To" />
</div>
</div>
<div className="space-y-1.5">
<Label>Harvest window</Label>
<div className="flex items-center gap-1.5">
<MonthSelect value={f.harvestStart} onChange={(v) => set("harvestStart", v)} placeholder="From" />
<span className="text-muted-foreground text-xs">to</span>
<MonthSelect value={f.harvestEnd} onChange={(v) => set("harvestEnd", v)} placeholder="To" />
</div>
</div>
<div className="space-y-1.5">
<Label>Toxic to pets?</Label>
<Select
value={f.toxicToPets == null ? NONE : f.toxicToPets ? "yes" : "no"}
onValueChange={(v) => set("toxicToPets", v === NONE ? null : v === "yes")}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Unknown</SelectItem>
<SelectItem value="yes">Yes toxic</SelectItem>
<SelectItem value="no">No safe</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Spacing</Label>
<Input value={f.spacing ?? ""} onChange={(e) => set("spacing", e.target.value)} placeholder="1015 ft" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Edible parts</Label>
<Input value={f.edibleParts ?? ""} onChange={(e) => set("edibleParts", e.target.value)}
placeholder="Fruit, young leaves" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Care notes</Label>
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}
placeholder="How to care for this kind of plant in general…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Other notes</Label>
<Textarea rows={2} value={f.notes ?? ""} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddPlantTypeButton() {
return (
<PlantTypeDialog
title="Add a plant type"
initial={blank()}
onSaved={() => {}}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add type</Button>}
/>
);
}
export function EditPlantTypeButton({ plantType }: { plantType: PlantTypeFields }) {
return (
<PlantTypeDialog
title="Edit plant type"
initial={plantType}
onSaved={() => {}}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>}
/>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { ClipboardList } from "lucide-react";
import { ITEM_LOG_TYPES, ITEM_LOG_LABELS } from "@/lib/inventory/item-fields";
export function AddItemLogButton({ itemId }: { itemId: string }) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("MAINTENANCE");
const [notes, setNotes] = useState("");
const [cost, setCost] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/v1/items/${itemId}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined, cost: cost ? Number(cost) : null }),
});
setBusy(false);
if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
toast({ title: "Logged!" });
setNotes(""); setCost(""); setOpen(false);
router.refresh();
}
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<ClipboardList className="h-4 w-4 mr-1" /> Log
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Log for this item</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ITEM_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ITEM_LOG_LABELS[t]}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" autoFocus />
</div>
<div className="space-y-1.5">
<Label>Cost ($, optional)</Label>
<Input type="number" min="0" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { ImagePlus } from "lucide-react";
export function AttachmentUpload({ itemId }: { itemId: string }) {
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const { toast } = useToast();
async function onFile(file: File) {
setBusy(true);
const form = new FormData();
form.append("file", file);
form.append("kind", "PHOTO");
const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
setBusy(false);
if (res.ok) { toast({ title: "Photo added" }); router.refresh(); }
else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
}
return (
<>
<input ref={inputRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}>
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"}
</Button>
</>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Trash2 } from "lucide-react";
export function DeleteItemButton({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleDelete() {
setBusy(true);
const res = await fetch(`/api/v1/items/${id}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not delete.", variant: "destructive" });
return;
}
toast({ title: "Deleted", description: `"${name}" removed.` });
setOpen(false);
router.push("/inventory");
router.refresh();
}
return (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" /> Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>
The item is hidden but its history is kept. Anything stored inside it stays in the system.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import {
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
} from "lucide-react";
import { warrantyStatus } from "@/lib/inventory/value";
export type GridItem = {
id: string;
name: string;
quantity: string | number;
unit: string | null;
value: string | number | null;
warrantyExpiry: string | Date | null;
imageUrl: string | null;
locationId: string | null;
parentItemId: string | null;
location: { id: string; name: string } | null;
labels: { label: { id: string; name: string; color: string | null } }[];
_count: { containedItems: number; logs: number };
};
const now = new Date();
function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
const s = warrantyStatus(expiry, now);
if (s === "none") return null;
const map = {
active: { icon: ShieldCheck, cls: "text-green-600 border-green-600/30", label: "Warranty" },
expiring: { icon: ShieldAlert, cls: "text-amber-600 border-amber-600/30", label: "Warranty soon" },
expired: { icon: ShieldX, cls: "text-red-600 border-red-600/30", label: "Warranty out" },
}[s];
const Icon = map.icon;
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
}
function ItemRow({ item, childrenByParent, depth }: {
item: GridItem;
childrenByParent: Map<string, GridItem[]>;
depth: number;
}) {
const [open, setOpen] = useState(false);
const kids = childrenByParent.get(item.id) ?? [];
const qty = Number(item.quantity);
return (
<div>
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors" style={{ paddingLeft: depth * 18 + 12 }}>
{kids.length > 0 ? (
<button onClick={() => setOpen((v) => !v)} className="text-muted-foreground shrink-0">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : <span className="w-4 shrink-0" />}
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
) : (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
)}
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span>
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
</Link>
<div className="flex items-center gap-1.5 shrink-0">
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
</div>
{open && kids.map((k) => (
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
))}
</div>
);
}
export function InventoryGrid({ items }: { items: GridItem[] }) {
if (items.length === 0) {
return (
<div className="text-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No items yet</p>
<p className="text-sm mt-1">Add your first tool, gadget, or piece of gear.</p>
</div>
);
}
// Items contained inside another item are rendered under their parent.
const childrenByParent = new Map<string, GridItem[]>();
for (const it of items) {
if (it.parentItemId) {
const arr = childrenByParent.get(it.parentItemId) ?? [];
arr.push(it);
childrenByParent.set(it.parentItemId, arr);
}
}
// Top-level items (not inside another item) grouped by location.
const topLevel = items.filter((i) => !i.parentItemId);
const groups = new Map<string, { name: string; items: GridItem[] }>();
const noLoc: GridItem[] = [];
for (const it of topLevel) {
if (!it.location) { noLoc.push(it); continue; }
const g = groups.get(it.location.id) ?? { name: it.location.name, items: [] };
g.items.push(it);
groups.set(it.location.id, g);
}
const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="space-y-6">
{sorted.map((g) => (
<div key={g.name} className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium">
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display">{g.name}</span>
<span className="text-xs text-muted-foreground font-normal">· {g.items.length}</span>
</div>
<div className="divide-y border rounded-lg overflow-hidden">
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div>
</div>
))}
{noLoc.length > 0 && (
<div className="space-y-1.5">
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
<div className="divide-y border rounded-lg overflow-hidden">
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react";
export type LocationOpt = { id: string; name: string };
export type ItemOpt = { id: string; name: string };
export type LabelOpt = { id: string; name: string; color: string | null };
export type ItemFields = {
id?: string;
name: string;
description: string;
quantity: string;
unit: string;
locationId: string;
parentItemId: string;
brand: string;
modelNumber: string;
serial: string;
value: string;
purchaseDate: string;
warrantyExpiry: string;
barcode: string;
notes: string;
labelIds: string[];
};
export function blankItem(): ItemFields {
return {
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "",
barcode: "", notes: "", labelIds: [],
};
}
const NONE = "__none__";
function ItemDialog({
trigger, title, initial, locations, items, labels: initialLabels,
}: {
trigger: React.ReactNode;
title: string;
initial: ItemFields;
locations: LocationOpt[];
items: ItemOpt[];
labels: LabelOpt[];
}) {
const [open, setOpen] = useState(false);
const [f, setF] = useState<ItemFields>(initial);
const [labels, setLabels] = useState<LabelOpt[]>(initialLabels);
const [newLabel, setNewLabel] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof ItemFields>(k: K, v: ItemFields[K]) {
setF((p) => ({ ...p, [k]: v }));
}
function toggleLabel(id: string) {
setF((p) => ({ ...p, labelIds: p.labelIds.includes(id) ? p.labelIds.filter((x) => x !== id) : [...p.labelIds, id] }));
}
async function addLabel() {
const name = newLabel.trim();
if (!name) return;
const res = await fetch("/api/v1/labels", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }),
});
if (res.ok) {
const lab = await res.json();
setLabels((p) => (p.some((l) => l.id === lab.id) ? p : [...p, lab]));
toggleLabel(lab.id);
setNewLabel("");
}
}
// Items selectable as a parent — exclude self.
const parentOptions = items.filter((i) => i.id !== f.id);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!f.name.trim()) { setError("Please give the item a name."); return; }
setError("");
setBusy(true);
const payload = {
name: f.name.trim(),
description: f.description.trim() || undefined,
quantity: f.quantity ? Number(f.quantity) : undefined,
unit: f.unit.trim() || undefined,
locationId: f.locationId || null,
parentItemId: f.parentItemId || null,
brand: f.brand.trim() || undefined,
modelNumber: f.modelNumber.trim() || undefined,
serial: f.serial.trim() || undefined,
value: f.value ? Number(f.value) : null,
purchaseDate: f.purchaseDate || null,
warrantyExpiry: f.warrantyExpiry || null,
barcode: f.barcode.trim() || undefined,
notes: f.notes.trim() || undefined,
labelIds: f.labelIds,
};
const res = await fetch(f.id ? `/api/v1/items/${f.id}` : "/api/v1/items", {
method: f.id ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
return;
}
toast({ title: f.id ? "Saved!" : "Item added!" });
setOpen(false);
router.refresh();
}
return (
<>
<span onClick={() => { setF(initial); setLabels(initialLabels); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Name *</Label>
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Cordless drill" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
<Input type="number" min="0" step="any" value={f.quantity} onChange={(e) => set("quantity", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Unit</Label>
<Input value={f.unit} onChange={(e) => set("unit", e.target.value)} placeholder="each" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Inside item</Label>
<Select value={f.parentItemId || NONE} onValueChange={(v) => set("parentItemId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> (not inside another item)</SelectItem>
{parentOptions.map((i) => <SelectItem key={i.id} value={i.id}>{i.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Brand</Label>
<Input value={f.brand} onChange={(e) => set("brand", e.target.value)} placeholder="DeWalt" />
</div>
<div className="space-y-1.5">
<Label>Model #</Label>
<Input value={f.modelNumber} onChange={(e) => set("modelNumber", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Serial #</Label>
<Input value={f.serial} onChange={(e) => set("serial", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Value ($)</Label>
<Input type="number" min="0" step="0.01" value={f.value} onChange={(e) => set("value", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Purchased</Label>
<Input type="date" value={f.purchaseDate} onChange={(e) => set("purchaseDate", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Warranty until</Label>
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Labels</Label>
<div className="flex flex-wrap gap-1.5">
{labels.map((l) => (
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)}>
<Badge variant={f.labelIds.includes(l.id) ? "default" : "outline"} className="cursor-pointer">
{l.name}
</Badge>
</button>
))}
</div>
<div className="flex gap-2 mt-1">
<Input value={newLabel} onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addLabel(); } }}
placeholder="New label…" className="h-8 text-sm" />
<Button type="button" size="sm" variant="outline" onClick={addLabel}>Add</Button>
</div>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddItemButton(props: { locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
return (
<ItemDialog title="Add an item" initial={blankItem()} {...props}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add item</Button>} />
);
}
export function EditItemButton(props: { item: ItemFields; locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
const { item, ...rest } = props;
return (
<ItemDialog title="Edit item" initial={item} {...rest}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
);
}

View File

@@ -2,12 +2,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw } from "lucide-react";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
@@ -16,6 +18,7 @@ const navItems = [
const settingsItems = [
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound },
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
];
@@ -34,7 +37,14 @@ export function Sidebar() {
<nav className="flex-1 px-2 py-3 space-y-0.5">
{navItems.map(({ label, href, icon: Icon }) => {
const active = path === href || (href !== "/dashboard" && path.startsWith(href));
// Active on exact match or a sub-path, unless a more specific nav item
// (e.g. /garden/types) already claims the current path.
const moreSpecific = navItems.some(
(o) => o.href.length > href.length && (path === o.href || path.startsWith(o.href + "/"))
);
const active =
path === href ||
(href !== "/dashboard" && path.startsWith(href + "/") && !moreSpecific);
return (
<Link
key={href}

View File

@@ -0,0 +1,217 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Plus, Trash2, KeyRound, Copy, Check, AlertTriangle } from "lucide-react";
import { SCOPE_RESOURCES } from "@/lib/api/scopes";
type Token = {
id: string; name: string; prefix: string; scopes: string[];
lastUsedAt: string | null; expiresAt: string | null; revokedAt: string | null; createdAt: string;
};
function fmt(d: string | null) {
return d ? new Date(d).toLocaleDateString() : "—";
}
export function ApiTokensPanel() {
const [tokens, setTokens] = useState<Token[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [fullAccess, setFullAccess] = useState(false);
const [scopes, setScopes] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
async function load() {
setLoading(true);
const res = await fetch("/api/account/tokens");
if (res.ok) setTokens(await res.json());
setLoading(false);
}
useEffect(() => { load(); }, []);
function toggleScope(s: string) {
setScopes((prev) => {
const next = new Set(prev);
next.has(s) ? next.delete(s) : next.add(s);
return next;
});
}
function resetForm() {
setName(""); setFullAccess(false); setScopes(new Set()); setNewToken(null); setCopied(false);
}
async function create(e: React.FormEvent) {
e.preventDefault();
const chosen = fullAccess ? ["*"] : Array.from(scopes);
if (!name.trim() || chosen.length === 0) {
toast({ title: "Add a name and at least one permission.", variant: "destructive" });
return;
}
setBusy(true);
const res = await fetch("/api/account/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), scopes: chosen }),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not create token.", variant: "destructive" });
return;
}
const data = await res.json();
setNewToken(data.token);
load();
}
async function revoke(id: string, label: string) {
if (!confirm(`Revoke "${label}"? Anything using it will stop working.`)) return;
const res = await fetch(`/api/account/tokens/${id}`, { method: "DELETE" });
if (res.ok) { toast({ title: "Token revoked" }); load(); }
else toast({ title: "Error", description: "Could not revoke.", variant: "destructive" });
}
async function copy() {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Tokens let other apps (Home Assistant, phone shortcuts, scripts, Claude) read and update
your homestead over the API. Each token only gets the permissions you grant it.
</p>
<Button size="sm" onClick={() => { resetForm(); setOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> New token
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : tokens.length === 0 ? (
<div className="text-center py-10 text-muted-foreground border rounded-lg">
<KeyRound className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">No tokens yet.</p>
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{tokens.map((t) => {
const expired = t.expiresAt && new Date(t.expiresAt) < new Date();
const dead = !!t.revokedAt || expired;
return (
<div key={t.id} className="flex items-center gap-3 px-4 py-3">
<KeyRound className={`h-4 w-4 shrink-0 ${dead ? "text-muted-foreground/40" : "text-[hsl(var(--leaf))]"}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-medium ${dead ? "line-through text-muted-foreground" : ""}`}>{t.name}</span>
<code className="text-xs text-muted-foreground">{t.prefix}</code>
{t.revokedAt && <Badge variant="outline" className="text-xs">Revoked</Badge>}
{expired && !t.revokedAt && <Badge variant="outline" className="text-xs">Expired</Badge>}
</div>
<div className="flex items-center gap-1.5 flex-wrap mt-1">
{t.scopes.map((s) => <Badge key={s} variant="secondary" className="text-[10px]">{s}</Badge>)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Created {fmt(t.createdAt)} · Last used {fmt(t.lastUsedAt)}
{t.expiresAt && ` · Expires ${fmt(t.expiresAt)}`}
</p>
</div>
{!dead && (
<Button variant="ghost" size="sm" className="text-[hsl(var(--ember))] shrink-0"
onClick={() => revoke(t.id, t.name)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</div>
)}
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm(); }}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
{newToken ? (
<>
<DialogHeader>
<DialogTitle>Copy your new token</DialogTitle>
<DialogDescription className="flex items-start gap-1.5 text-[hsl(var(--ember))]">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
This is the only time you&apos;ll see it. Copy it somewhere safe now.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted rounded p-2.5 break-all">{newToken}</code>
<Button size="sm" variant="outline" onClick={copy}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<DialogFooter>
<Button onClick={() => { setOpen(false); resetForm(); }}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={create}>
<DialogHeader>
<DialogTitle>New API token</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Home Assistant, phone shortcut…" />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={fullAccess} onChange={(e) => setFullAccess(e.target.checked)}
className="accent-[hsl(var(--leaf))]" />
Full access (read &amp; write everything)
</label>
{!fullAccess && (
<div className="space-y-1.5">
<Label>Permissions</Label>
<div className="border rounded-md divide-y">
<div className="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-1.5 text-xs text-muted-foreground">
<span></span><span className="w-12 text-center">Read</span><span className="w-12 text-center">Write</span>
</div>
{SCOPE_RESOURCES.map((r) => (
<div key={r.key} className="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 items-center text-sm">
<span>{r.label}{r.soon && <span className="text-xs text-muted-foreground"> · soon</span>}</span>
<input type="checkbox" className="w-12 accent-[hsl(var(--leaf))]"
checked={scopes.has(`${r.key}:read`)} onChange={() => toggleScope(`${r.key}:read`)} />
<input type="checkbox" className="w-12 accent-[hsl(var(--leaf))]"
checked={scopes.has(`${r.key}:write`)} onChange={() => toggleScope(`${r.key}:write`)} />
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Creating…" : "Create token"}</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useState, useRef } from "react";
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export function BackupPanel() {
const [restoreStatus, setRestoreStatus] = useState<
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null
>(null);
const [restoring, setRestoring] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
if (!file.name.endsWith(".zip")) {
setRestoreStatus({ type: "error", message: "Please upload a .zip backup file." });
return;
}
if (!confirm("This will REPLACE all current data with the backup. Are you sure?")) return;
setRestoring(true);
setRestoreStatus(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/admin/restore", { method: "POST", body: form });
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Restore failed");
setRestoreStatus({ type: "success", exportedAt: json.exportedAt });
} catch (err) {
setRestoreStatus({ type: "error", message: err instanceof Error ? err.message : "Restore failed" });
} finally {
setRestoring(false);
}
}
return (
<div className="max-w-xl space-y-8">
<div>
<h1 className="text-2xl font-bold">Backup &amp; Restore</h1>
<p className="mt-1 text-sm text-muted-foreground">
Download a full backup of all Moon Base data, or restore from a previous backup.
</p>
</div>
{/* Backup */}
<section className="rounded-lg border p-6 space-y-3">
<h2 className="font-semibold text-lg">Download Backup</h2>
<p className="text-sm text-muted-foreground">
Downloads all plants, logs, tasks, and settings as a <code>.zip</code> file. Keep it somewhere safe.
</p>
<a href="/api/admin/backup" download>
<Button className="gap-2">
<Download className="h-4 w-4" />
Download backup
</Button>
</a>
</section>
{/* Restore */}
<section className="rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-lg">Restore from Backup</h2>
<p className="text-sm text-muted-foreground">
Upload a <code>.zip</code> backup file to restore. <strong>This replaces everything</strong> all current data will be overwritten.
</p>
<div
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer ${
dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"}
</p>
<input
ref={inputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{restoreStatus?.type === "success" && (
<div className="flex items-start gap-2 rounded-md bg-green-50 border border-green-200 p-3 text-sm text-green-800">
<CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
Restore complete. Data from backup exported on{" "}
<strong>{new Date(restoreStatus.exportedAt).toLocaleString()}</strong> is now live.
Reload the page to see it.
</div>
</div>
)}
{restoreStatus?.type === "error" && (
<div className="flex items-start gap-2 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div>{restoreStatus.message}</div>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { hasScope, generateRawToken, hashToken, tokenPrefix, TOKEN_PREFIX } from "./authenticate";
describe("hasScope", () => {
it("grants everything with the global wildcard", () => {
expect(hasScope(["*"], "plants:read")).toBe(true);
expect(hasScope(["*"], "items:write")).toBe(true);
});
it("matches an exact scope", () => {
expect(hasScope(["plants:read"], "plants:read")).toBe(true);
expect(hasScope(["plants:read"], "plants:write")).toBe(false);
expect(hasScope(["plants:read"], "items:read")).toBe(false);
});
it("honors a resource wildcard", () => {
expect(hasScope(["plants:*"], "plants:read")).toBe(true);
expect(hasScope(["plants:*"], "plants:write")).toBe(true);
expect(hasScope(["plants:*"], "items:read")).toBe(false);
});
it("lets write imply read, but not the reverse", () => {
expect(hasScope(["plants:write"], "plants:read")).toBe(true);
expect(hasScope(["plants:read"], "plants:write")).toBe(false);
});
it("returns false for an empty scope set", () => {
expect(hasScope([], "plants:read")).toBe(false);
});
});
describe("token helpers", () => {
it("generates a prefixed, unique-ish token and hashes deterministically", () => {
const a = generateRawToken();
const b = generateRawToken();
expect(a.startsWith(TOKEN_PREFIX)).toBe(true);
expect(a).not.toBe(b);
expect(hashToken(a)).toBe(hashToken(a)); // deterministic
expect(hashToken(a)).not.toBe(hashToken(b));
expect(hashToken(a)).toMatch(/^[0-9a-f]{64}$/); // sha256 hex
});
it("derives an 8-char prefix for display", () => {
const raw = generateRawToken();
expect(tokenPrefix(raw)).toBe(raw.slice(0, 8));
expect(tokenPrefix(raw)).toHaveLength(8);
});
it("ignores surrounding whitespace when hashing", () => {
const raw = generateRawToken();
expect(hashToken(` ${raw} `)).toBe(hashToken(raw));
});
});

View File

@@ -0,0 +1,97 @@
import { createHash, randomBytes } from "crypto";
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { UserRole } from "@prisma/client";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
// ---------------------------------------------------------------------------
// Token format + hashing (pure)
// ---------------------------------------------------------------------------
export const TOKEN_PREFIX = "mb_";
/** A fresh raw token, e.g. "mb_<43 base64url chars>". Shown to the user once. */
export function generateRawToken(): string {
return TOKEN_PREFIX + randomBytes(32).toString("base64url");
}
/** Only the hash is stored; lookups hash the incoming bearer and match. */
export function hashToken(raw: string): string {
return createHash("sha256").update(raw.trim()).digest("hex");
}
/** Short identifier shown in the UI so a token is recognizable post-creation. */
export function tokenPrefix(raw: string): string {
return raw.slice(0, 8);
}
// ---------------------------------------------------------------------------
// Scopes (pure) — "<resource>:<action>", plus "*" and "<resource>:*" wildcards.
// "write" implies "read". Cookie sessions hold ["*"] (full UI access).
// Scope constants live in ./scopes (client-safe); re-exported for convenience.
// ---------------------------------------------------------------------------
export { KNOWN_SCOPES } from "./scopes";
export function hasScope(scopes: string[], required: string): boolean {
const [reqRes, reqAct] = required.split(":");
for (const s of scopes) {
if (s === "*") return true;
const [res, act] = s.split(":");
if (res !== reqRes) continue;
if (act === "*" || act === reqAct) return true;
if (act === "write" && reqAct === "read") return true; // write implies read
}
return false;
}
// ---------------------------------------------------------------------------
// Request authentication — accepts a NextAuth cookie session OR a Bearer PAT
// ---------------------------------------------------------------------------
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
export type AuthContext = {
userId: string;
role: UserRole;
scopes: string[]; // ["*"] for cookie sessions
via: "session" | "token";
};
export async function authenticateRequest(req: Request, required?: string): Promise<AuthContext> {
const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
if (bearer) {
const tok = await db.apiToken.findUnique({
where: { tokenHash: hashToken(bearer) },
include: { user: { select: { role: true, active: true } } },
});
if (!tok || tok.revokedAt || !tok.user.active || (tok.expiresAt && tok.expiresAt < new Date()))
throw new ApiError(401, "Invalid or expired token");
if (required && !hasScope(tok.scopes, required))
throw new ApiError(403, `Token is missing the "${required}" scope`);
// Fire-and-forget — never block the request on a usage timestamp.
db.apiToken.update({ where: { id: tok.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
}
const session = await getSession();
if (!session) throw new ApiError(401, "Unauthorized");
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
}
/** Standardize the error ladder shared by every /api/v1 route. */
export function handleApiError(err: unknown): NextResponse {
if (err instanceof ApiError)
return NextResponse.json({ error: err.message }, { status: err.status });
if (err instanceof ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}

61
src/lib/api/schemas.ts Normal file
View File

@@ -0,0 +1,61 @@
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
// validation lives in exactly one place.
import { z } from "zod";
export const plantLogInput = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(), // ISO date; defaults to now
});
export const taskCreateInput = z.object({
title: z.string().min(1),
notes: z.string().optional(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
plantId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(),
nextDue: z.string(), // ISO date
});
export const taskCompleteInput = z.object({ notes: z.string().optional() });
// --- Inventory ---
export const itemCreateInput = z.object({
name: z.string().min(1),
description: z.string().optional(),
kind: z.enum(["DURABLE", "CONSUMABLE"]).default("DURABLE"),
quantity: z.coerce.number().nonnegative().optional(),
unit: z.string().optional(),
locationId: z.string().nullable().optional(),
parentItemId: z.string().nullable().optional(),
value: z.coerce.number().nonnegative().nullable().optional(),
purchaseDate: z.string().nullable().optional(),
warrantyExpiry: z.string().nullable().optional(),
serial: z.string().optional(),
modelNumber: z.string().optional(),
brand: z.string().optional(),
barcode: z.string().optional(),
notes: z.string().optional(),
imageUrl: z.string().nullable().optional(),
labelIds: z.array(z.string()).optional(),
});
export const itemUpdateInput = itemCreateInput.partial();
export const itemLogInput = z.object({
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
notes: z.string().optional(),
cost: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
export type PlantLogInput = z.infer<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
export type ItemLogInput = z.infer<typeof itemLogInput>;

22
src/lib/api/scopes.ts Normal file
View File

@@ -0,0 +1,22 @@
// Client-safe scope constants (no server deps) — shared by the auth helper,
// the token routes, and the token-management UI.
export const KNOWN_SCOPES = [
"plants:read", "plants:write",
"plant-types:read", "plant-types:write",
"locations:read", "locations:write",
"tasks:read", "tasks:write",
"items:read", "items:write",
] as const;
export type KnownScope = (typeof KNOWN_SCOPES)[number];
// Resources for the token-creation UI (read/write columns). `soon` marks
// domains whose endpoints are still being built.
export const SCOPE_RESOURCES: { key: string; label: string; soon?: boolean }[] = [
{ key: "plants", label: "Plants" },
{ key: "plant-types", label: "Plant types" },
{ key: "locations", label: "Locations" },
{ key: "tasks", label: "Tasks & reminders" },
{ key: "items", label: "Inventory", soon: true },
];

View File

@@ -29,7 +29,22 @@ declare module "next-auth/jwt" {
}
}
// Fail loudly in production if the JWT secret is missing or left at the
// placeholder — otherwise sessions are forgeable. Skipped during `next build`
// (no runtime env) and in development.
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
if (
process.env.NODE_ENV === "production" &&
process.env.NEXT_PHASE !== "phase-production-build" &&
(!NEXTAUTH_SECRET || NEXTAUTH_SECRET.length < 16 || NEXTAUTH_SECRET.includes("change-me"))
) {
throw new Error(
"NEXTAUTH_SECRET is missing or insecure — generate one with `openssl rand -base64 32` and set it in the environment."
);
}
export const authOptions: NextAuthOptions = {
secret: NEXTAUTH_SECRET,
session: { strategy: "jwt" },
pages: { signIn: "/login", error: "/login" },
providers: [

View File

@@ -8,6 +8,34 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.13.0",
date: "2026-06-16",
changes: [
"New Inventory section — keep track of the things you own: tools, gear, equipment. Record where each one lives, its value, brand, serial number, and warranty (you'll get a heads-up when one's expiring). Items can hold other items, so a toolbox shows the tools inside it. Add photos, labels to group things, and a maintenance/repair history per item.",
],
},
{
version: "0.12.0",
date: "2026-06-15",
changes: [
"Moon Base now has an API — other apps can read and update your homestead. Create access tokens under Settings → API tokens, choosing exactly what each one is allowed to do (e.g. read plants, log harvests). This is the groundwork for phone shortcuts, Home Assistant, and letting Claude help directly.",
],
},
{
version: "0.11.0",
date: "2026-06-15",
changes: [
"New \"Plant types\" library — a place to keep general info about each kind of plant (lily of the valley, Honeycrisp apple…): care notes, sun and water needs, bloom and harvest months, whether it's toxic to pets, and more. Every field is optional. Your plants link to their type automatically, so opening a plant now shows \"More about…\" and a type page lists every spot you've planted it.",
],
},
{
version: "0.10.0",
date: "2026-06-15",
changes: [
"Locations are now real, reusable places instead of just typed-in text. Pick where a plant lives from your saved spots, or add a new one on the fly — and the same locations will soon hold your tools, pantry, and more. Your existing plant locations carried over automatically.",
],
},
{
version: "0.9.1",
date: "2026-06-15",

View File

@@ -0,0 +1,63 @@
import type { PrismaClient } from "@prisma/client";
import { PLANT_DB } from "./plant-db";
// Stable, deterministic id for a library-seeded PlantType so upserts are
// idempotent across seed / deploy-backfill / re-runs.
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
export function libraryTypeId(commonName: string, species?: string | null): string {
return `pt-${slug(commonName)}${species ? `-${slug(species)}` : ""}`;
}
export type PlantTypeSyncResult = { types: number; linked: number };
/**
* Idempotently upsert a PlantType for every entry in the built-in plant library
* (src/lib/garden/plant-db.ts), then link any Plant lacking a plantTypeId to its
* kind — by species first, else common name. `update: {}` so re-runs never
* clobber a user's edits to a type. Safe to run repeatedly.
*/
export async function syncPlantTypesFromLibrary(db: PrismaClient): Promise<PlantTypeSyncResult> {
for (const entry of PLANT_DB) {
const id = libraryTypeId(entry.commonName, entry.species);
await db.plantType.upsert({
where: { id },
update: {},
create: {
id,
commonName: entry.commonName,
species: entry.species ?? null,
category: entry.category,
careNotes: entry.notes ?? null,
},
});
}
const types = await db.plantType.findMany({
select: { id: true, commonName: true, species: true },
});
const bySpecies = new Map<string, string>();
const byCommon = new Map<string, string>();
for (const t of types) {
if (t.species) bySpecies.set(t.species.toLowerCase().trim(), t.id);
byCommon.set(t.commonName.toLowerCase().trim(), t.id);
}
const unlinked = await db.plant.findMany({
where: { plantTypeId: null },
select: { id: true, commonName: true, species: true },
});
let linked = 0;
for (const p of unlinked) {
const typeId =
(p.species && bySpecies.get(p.species.toLowerCase().trim())) ||
byCommon.get(p.commonName.toLowerCase().trim());
if (typeId) {
await db.plant.update({ where: { id: p.id }, data: { plantTypeId: typeId } });
linked++;
}
}
return { types: PLANT_DB.length, linked };
}

View File

@@ -0,0 +1,26 @@
import { ItemLogType } from "@prisma/client";
export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
NOTE: "Note",
MAINTENANCE: "Maintenance",
MOVED: "Moved",
PURCHASED: "Purchased",
REPAIRED: "Repaired",
DISPOSED: "Disposed",
};
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
NOTE: "text-slate-600 dark:text-slate-400",
MAINTENANCE: "text-amber-600 dark:text-amber-400",
MOVED: "text-blue-600 dark:text-blue-400",
PURCHASED: "text-green-600 dark:text-green-400",
REPAIRED: "text-violet-600 dark:text-violet-400",
DISPOSED: "text-red-600 dark:text-red-400",
};
export const ITEM_LOG_TYPES: ItemLogType[] = [
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
];
export const ATTACHMENT_KINDS = ["PHOTO", "MANUAL", "RECEIPT"] as const;
export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { warrantyStatus, totalValue } from "./value";
const now = new Date("2026-06-16T00:00:00Z");
describe("warrantyStatus", () => {
it("returns none when there's no expiry", () => {
expect(warrantyStatus(null, now)).toBe("none");
expect(warrantyStatus(undefined, now)).toBe("none");
});
it("flags expired warranties", () => {
expect(warrantyStatus(new Date("2026-06-15T00:00:00Z"), now)).toBe("expired");
});
it("flags warranties expiring within the window", () => {
expect(warrantyStatus(new Date("2026-07-01T00:00:00Z"), now)).toBe("expiring"); // ~15 days
});
it("treats a comfortably future expiry as active", () => {
expect(warrantyStatus(new Date("2027-01-01T00:00:00Z"), now)).toBe("active");
});
it("honors a custom soon window", () => {
expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 90)).toBe("expiring");
expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 7)).toBe("active");
});
});
describe("totalValue", () => {
it("sums value × quantity", () => {
expect(totalValue([{ value: 2, quantity: 5 }, { value: 100, quantity: 1 }])).toBe(110);
});
it("defaults quantity to 1 and missing value to 0", () => {
expect(totalValue([{ value: 50 }, { quantity: 3 }, {}])).toBe(50);
});
it("accepts Decimal-style strings", () => {
expect(totalValue([{ value: "19.99", quantity: "2" }])).toBeCloseTo(39.98);
});
it("is zero for an empty inventory", () => {
expect(totalValue([])).toBe(0);
});
});

View File

@@ -0,0 +1,39 @@
// Pure inventory helpers — warranty status + total value. Decimal columns come
// back as strings/Decimal from Prisma, so callers pass plain numbers here.
export type WarrantyStatus = "none" | "active" | "expiring" | "expired";
/**
* Where a warranty stands relative to `now`. "expiring" = within `soonDays`.
*/
export function warrantyStatus(
warrantyExpiry: Date | string | null | undefined,
now: Date,
soonDays = 30,
): WarrantyStatus {
if (!warrantyExpiry) return "none";
const exp = new Date(warrantyExpiry).getTime();
const t = now.getTime();
if (Number.isNaN(exp)) return "none";
if (exp < t) return "expired";
if (exp - t <= soonDays * 86_400_000) return "expiring";
return "active";
}
export const WARRANTY_LABELS: Record<WarrantyStatus, string> = {
none: "",
active: "Under warranty",
expiring: "Warranty expiring soon",
expired: "Warranty expired",
};
type Valued = { value?: number | string | null; quantity?: number | string | null };
/** Sum of value × quantity across items (missing value counts as 0). */
export function totalValue(items: Valued[]): number {
return items.reduce((sum, i) => {
const v = i.value != null ? Number(i.value) : 0;
const q = i.quantity != null ? Number(i.quantity) : 1;
return sum + (Number.isFinite(v) ? v * (Number.isFinite(q) ? q : 1) : 0);
}, 0);
}

40
src/lib/locations/db.ts Normal file
View File

@@ -0,0 +1,40 @@
// DB-touching Location helpers (kept thin; pure logic lives in tree.ts).
import { db } from "@/lib/db";
import { computePath, indexById, descendantIds } from "./tree";
type Client = typeof db;
/**
* Recompute the denormalized `path` breadcrumb on every Location. Homestead
* trees are tiny (dozens of rows), so a full recompute on any structural change
* is simpler and always-correct versus targeted descendant updates. Pass a
* transaction client to run inside the same tx as the mutation.
*/
export async function recomputeAllPaths(client: Client = db): Promise<void> {
const rows = await client.location.findMany({
select: { id: true, name: true, parentId: true },
});
const byId = indexById(rows);
for (const r of rows) {
await client.location.update({
where: { id: r.id },
data: { path: computePath(r.id, byId) || r.name },
});
}
}
/**
* Would setting `candidateParentId` as the parent of `nodeId` create a cycle?
* True if the candidate is the node itself or one of its descendants.
*/
export async function wouldCreateCycle(
nodeId: string,
candidateParentId: string,
client: Client = db,
): Promise<boolean> {
if (nodeId === candidateParentId) return true;
const rows = await client.location.findMany({
select: { id: true, parentId: true, name: true },
});
return descendantIds(nodeId, rows, true).includes(candidateParentId);
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
buildTree,
indexById,
computePath,
descendantIds,
flattenForSelect,
PATH_SEP,
} from "./tree";
// House Basement Shelf B, plus a sibling room and a standalone garden area.
const rows = [
{ id: "house", name: "House", parentId: null },
{ id: "base", name: "Basement", parentId: "house" },
{ id: "shelfB", name: "Shelf B", parentId: "base" },
{ id: "shelfA", name: "Shelf A", parentId: "base" },
{ id: "kitchen", name: "Kitchen", parentId: "house" },
{ id: "garden", name: "Back fence guild", parentId: null },
];
describe("buildTree", () => {
it("nests children under parents and sorts siblings by name", () => {
const roots = buildTree(rows);
// Roots sorted: "Back fence guild" before "House"
expect(roots.map((r) => r.id)).toEqual(["garden", "house"]);
const house = roots.find((r) => r.id === "house")!;
// House children sorted: Basement, Kitchen
expect(house.children.map((c) => c.id)).toEqual(["base", "kitchen"]);
// Basement children sorted: Shelf A, Shelf B
const base = house.children.find((c) => c.id === "base")!;
expect(base.children.map((c) => c.id)).toEqual(["shelfA", "shelfB"]);
});
it("assigns depth by nesting level", () => {
const roots = buildTree(rows);
const house = roots.find((r) => r.id === "house")!;
expect(house.depth).toBe(0);
expect(house.children[0].depth).toBe(1); // Basement
expect(house.children[0].children[0].depth).toBe(2); // Shelf A
});
it("treats rows with a missing parent as roots (orphans don't vanish)", () => {
const orphan = [{ id: "x", name: "Orphan", parentId: "ghost" }];
expect(buildTree(orphan).map((r) => r.id)).toEqual(["x"]);
});
it("is cycle-safe", () => {
const cyclic = [
{ id: "a", name: "A", parentId: "b" },
{ id: "b", name: "B", parentId: "a" },
];
// Neither is a real root; build must not infinite-loop.
expect(() => buildTree(cyclic)).not.toThrow();
});
});
describe("computePath", () => {
it("renders the breadcrumb from root to node", () => {
const byId = indexById(rows);
expect(computePath("shelfB", byId)).toBe(
["House", "Basement", "Shelf B"].join(PATH_SEP),
);
expect(computePath("garden", byId)).toBe("Back fence guild");
});
it("is cycle-safe", () => {
const byId = indexById([
{ id: "a", name: "A", parentId: "b" },
{ id: "b", name: "B", parentId: "a" },
]);
expect(() => computePath("a", byId)).not.toThrow();
});
});
describe("descendantIds", () => {
it("collects all descendants, excluding self by default", () => {
expect(descendantIds("house", rows).sort()).toEqual(
["base", "kitchen", "shelfA", "shelfB"].sort(),
);
});
it("includes self when asked", () => {
expect(descendantIds("base", rows, true).sort()).toEqual(
["base", "shelfA", "shelfB"].sort(),
);
});
it("returns just self (or nothing) for a leaf", () => {
expect(descendantIds("shelfA", rows)).toEqual([]);
expect(descendantIds("shelfA", rows, true)).toEqual(["shelfA"]);
});
});
describe("flattenForSelect", () => {
it("produces a depth-ordered, pre-order list", () => {
const flat = flattenForSelect(buildTree(rows));
expect(flat.map((n) => `${n.depth}:${n.id}`)).toEqual([
"0:garden",
"0:house",
"1:base",
"2:shelfA",
"2:shelfB",
"1:kitchen",
]);
// No children key leaks into the flat rows.
expect((flat[0] as Record<string, unknown>).children).toBeUndefined();
});
});

123
src/lib/locations/tree.ts Normal file
View File

@@ -0,0 +1,123 @@
// Pure helpers for the shared Location tree. Prisma-free so they're trivially
// testable — callers pass plain rows ({ id, name, parentId }). Tree depth on a
// homestead is tiny, so everything here is simple in-memory work, no recursive
// SQL. See tree.test.ts.
export const PATH_SEP = " ";
/** Minimum shape the tree helpers need. Real Location rows satisfy this. */
export interface LocationRow {
id: string;
name: string;
parentId?: string | null;
}
export type TreeNode<T extends LocationRow> = T & {
children: TreeNode<T>[];
depth: number;
};
/**
* Build a sorted forest from flat rows. Rows whose parent is missing (filtered
* out, inactive, or dangling) become roots. Cycle-safe: a node only ever has
* one parent slot, so a cycle simply drops those nodes from the roots — they
* won't crash the walk.
*/
export function buildTree<T extends LocationRow>(rows: T[]): TreeNode<T>[] {
const byId = new Map<string, TreeNode<T>>();
for (const r of rows) byId.set(r.id, { ...r, children: [], depth: 0 });
const roots: TreeNode<T>[] = [];
for (const r of rows) {
const node = byId.get(r.id)!;
const parent = r.parentId ? byId.get(r.parentId) : undefined;
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
}
const sortRec = (nodes: TreeNode<T>[], depth: number) => {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const n of nodes) {
n.depth = depth;
sortRec(n.children, depth + 1);
}
};
sortRec(roots, 0);
return roots;
}
/** Build an id→row lookup for computePath / repeated parent walks. */
export function indexById<T extends LocationRow>(rows: T[]): Map<string, T> {
return new Map(rows.map((r) => [r.id, r]));
}
/**
* Breadcrumb path from root to the given node, e.g. "House Basement Shelf B".
* Walks parent links; cycle-safe via a seen-set.
*/
export function computePath<T extends LocationRow>(
id: string,
byId: Map<string, T>,
sep: string = PATH_SEP,
): string {
const names: string[] = [];
const seen = new Set<string>();
let cur: T | undefined = byId.get(id);
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
names.unshift(cur.name);
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
}
return names.join(sep);
}
/**
* All descendant ids of `id` (children, grandchildren, …). `includeSelf` adds
* `id` itself — handy for "log care to this location and everything under it".
* Cycle-safe.
*/
export function descendantIds<T extends LocationRow>(
id: string,
rows: T[],
includeSelf = false,
): string[] {
const childrenByParent = new Map<string, string[]>();
for (const r of rows) {
if (!r.parentId) continue;
const arr = childrenByParent.get(r.parentId) ?? [];
arr.push(r.id);
childrenByParent.set(r.parentId, arr);
}
const out: string[] = includeSelf ? [id] : [];
const seen = new Set<string>([id]);
const stack = [...(childrenByParent.get(id) ?? [])];
while (stack.length) {
const cur = stack.pop()!;
if (seen.has(cur)) continue;
seen.add(cur);
out.push(cur);
stack.push(...(childrenByParent.get(cur) ?? []));
}
return out;
}
/**
* Flatten a forest into a depth-ordered list for indented <select> options.
* Indent in the UI with `" ".repeat(depth * 2)` or similar.
*/
export function flattenForSelect<T extends LocationRow>(
roots: TreeNode<T>[],
): Array<T & { depth: number }> {
const out: Array<T & { depth: number }> = [];
const walk = (nodes: TreeNode<T>[]) => {
for (const n of nodes) {
const { children, ...rest } = n;
void children;
out.push(rest as unknown as T & { depth: number });
walk(n.children);
}
};
walk(roots);
return out;
}

173
src/lib/services/items.ts Normal file
View File

@@ -0,0 +1,173 @@
// Inventory service — DB work + AuditEvent for items. Shared by /api/v1 routes,
// the UI (which calls those routes), and later the MCP server.
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { isAdmin } from "@/lib/auth";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import type { ItemCreateInput, ItemUpdateInput, ItemLogInput } from "@/lib/api/schemas";
const listInclude = {
location: { select: { id: true, name: true } },
parentItem: { select: { id: true, name: true } },
labels: { include: { label: true } },
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
} satisfies Prisma.ItemInclude;
function toDate(s?: string | null): Date | null {
return s ? new Date(s) : null;
}
export function listItems(
_ctx: AuthContext,
opts?: { q?: string; locationId?: string; kind?: "DURABLE" | "CONSUMABLE" },
) {
return db.item.findMany({
where: {
active: true,
...(opts?.q ? { name: { contains: opts.q, mode: "insensitive" } } : {}),
...(opts?.locationId ? { locationId: opts.locationId } : {}),
...(opts?.kind ? { kind: opts.kind } : {}),
},
orderBy: { name: "asc" },
include: listInclude,
});
}
export async function getItem(_ctx: AuthContext, id: string) {
const item = await db.item.findUnique({
where: { id },
include: {
...listInclude,
attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" }, take: 50 },
containedItems: {
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { containedItems: { where: { active: true } } } } },
},
},
});
if (!item || !item.active) throw new ApiError(404, "Item not found");
return item;
}
// Walk up from a candidate parent; if we reach the item itself it's a cycle.
async function wouldCycle(itemId: string, candidateParentId: string): Promise<boolean> {
let cur: string | null = candidateParentId;
const seen = new Set<string>();
while (cur) {
if (cur === itemId) return true;
if (seen.has(cur)) break;
seen.add(cur);
const parent: { parentItemId: string | null } | null = await db.item.findUnique({
where: { id: cur },
select: { parentItemId: true },
});
cur = parent?.parentItemId ?? null;
}
return false;
}
export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
const { labelIds, ...r } = input;
const item = await db.item.create({
data: {
name: r.name.trim(),
description: r.description?.trim() || null,
kind: r.kind ?? "DURABLE",
quantity: r.quantity ?? 1,
unit: r.unit?.trim() || null,
locationId: r.locationId || null,
parentItemId: r.parentItemId || null,
value: r.value ?? null,
purchaseDate: toDate(r.purchaseDate),
warrantyExpiry: toDate(r.warrantyExpiry),
serial: r.serial?.trim() || null,
modelNumber: r.modelNumber?.trim() || null,
brand: r.brand?.trim() || null,
barcode: r.barcode?.trim() || null,
notes: r.notes?.trim() || null,
imageUrl: r.imageUrl || null,
qrSlug: createId().slice(0, 10),
...(labelIds?.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
},
include: listInclude,
});
await db.auditEvent.create({
data: { action: "item.create", entityId: item.id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
});
return item;
}
export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdateInput) {
const existing = await db.item.findUnique({ where: { id }, select: { active: true } });
if (!existing || !existing.active) throw new ApiError(404, "Item not found");
if (input.parentItemId) {
if (input.parentItemId === id) throw new ApiError(400, "An item can't contain itself");
if (await wouldCycle(id, input.parentItemId))
throw new ApiError(400, "That would put the item inside one of its own contents");
}
const { labelIds, ...r } = input;
const data: Prisma.ItemUpdateInput = {};
if (r.name !== undefined) data.name = r.name.trim();
if (r.description !== undefined) data.description = r.description?.trim() || null;
if (r.kind !== undefined) data.kind = r.kind;
if (r.quantity !== undefined) data.quantity = r.quantity;
if (r.unit !== undefined) data.unit = r.unit?.trim() || null;
if (r.locationId !== undefined) data.location = r.locationId ? { connect: { id: r.locationId } } : { disconnect: true };
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
if (r.value !== undefined) data.value = r.value;
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
const item = await db.$transaction(async (tx) => {
if (labelIds) {
await tx.labelOnItem.deleteMany({ where: { itemId: id } });
if (labelIds.length) await tx.labelOnItem.createMany({ data: labelIds.map((labelId) => ({ itemId: id, labelId })) });
}
return tx.item.update({ where: { id }, data, include: listInclude });
});
await db.auditEvent.create({
data: { action: "item.update", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
});
return item;
}
export async function deleteItem(ctx: AuthContext, id: string) {
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } });
if (!item || !item.active) throw new ApiError(404, "Item not found");
await db.item.update({ where: { id }, data: { active: false } });
await db.auditEvent.create({
data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
});
}
export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLogInput) {
const item = await db.item.findUnique({ where: { id: itemId }, select: { active: true } });
if (!item || !item.active) throw new ApiError(404, "Item not found");
const log = await db.itemLog.create({
data: {
itemId,
type: input.type,
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
cost: input.cost ?? null,
actorId: ctx.userId,
},
});
await db.auditEvent.create({
data: { action: "item.log.add", entityId: itemId, actorId: ctx.userId, payload: { type: input.type, via: ctx.via } },
});
return log;
}

View File

@@ -0,0 +1,10 @@
import { db } from "@/lib/db";
import type { AuthContext } from "@/lib/api/authenticate";
export function listLocations(_ctx: AuthContext) {
return db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: { id: true, name: true, kind: true, parentId: true, path: true },
});
}

View File

@@ -0,0 +1,12 @@
import { db } from "@/lib/db";
import type { AuthContext } from "@/lib/api/authenticate";
export function listPlantTypes(_ctx: AuthContext, opts?: { q?: string }) {
return db.plantType.findMany({
where: {
active: true,
...(opts?.q ? { commonName: { contains: opts.q, mode: "insensitive" } } : {}),
},
orderBy: { commonName: "asc" },
});
}

View File

@@ -0,0 +1,57 @@
// Plant service — DB work + AuditEvent for plantings. Called by /api/v1 routes
// and (later) the MCP server, so the logic lives once.
import { db } from "@/lib/db";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import type { PlantLogInput } from "@/lib/api/schemas";
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
return db.plant.findMany({
where: {
active: true,
...(opts?.q ? { commonName: { contains: opts.q, mode: "insensitive" } } : {}),
},
orderBy: { commonName: "asc" },
include: {
location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true } },
},
});
}
export async function getPlant(_ctx: AuthContext, id: string) {
const plant = await db.plant.findUnique({
where: { id },
include: {
location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true } },
logs: { orderBy: { date: "desc" }, take: 50 },
},
});
if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
return plant;
}
export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) {
const plant = await db.plant.findUnique({ where: { id: plantId }, select: { active: true } });
if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
const log = await db.plantLog.create({
data: {
plantId,
type: input.type,
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
quantity: input.quantity?.trim() || null,
actorId: ctx.userId,
},
});
await db.auditEvent.create({
data: {
action: "plant.log.add",
entityId: plantId,
actorId: ctx.userId,
payload: { type: input.type, via: ctx.via },
},
});
return log;
}

67
src/lib/services/tasks.ts Normal file
View File

@@ -0,0 +1,67 @@
import { db } from "@/lib/db";
import { nextDueDate } from "@/lib/tasks/schedule";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import type { TaskCreateInput, TaskCompleteInput } from "@/lib/api/schemas";
export function listTasks(_ctx: AuthContext) {
return db.task.findMany({
where: { active: true },
orderBy: { nextDue: "asc" },
include: { plant: { select: { id: true, commonName: true } } },
});
}
export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
const task = await db.task.create({
data: {
title: input.title.trim(),
notes: input.notes?.trim() || null,
category: input.category,
plantId: input.plantId || null,
recurrence: input.recurrence,
intervalDays: input.intervalDays ?? null,
month: input.month ?? null,
nextDue: new Date(input.nextDue),
},
});
await db.auditEvent.create({
data: {
action: "task.create",
entityId: task.id,
actorId: ctx.userId,
payload: { title: task.title, recurrence: task.recurrence, via: ctx.via },
},
});
return task;
}
export async function completeTask(ctx: AuthContext, id: string, input: TaskCompleteInput) {
const task = await db.task.findUnique({ where: { id } });
if (!task || !task.active) throw new ApiError(404, "Task not found");
const now = new Date();
const next = nextDueDate(task.recurrence, now, task.intervalDays, task.month);
const [completion] = await db.$transaction([
db.taskCompletion.create({
data: { taskId: task.id, doneAt: now, notes: input.notes?.trim() || null, actorId: ctx.userId },
}),
db.task.update({
where: { id: task.id },
data: {
lastDone: now,
nextDue: next ?? task.nextDue,
active: task.recurrence === "ONCE" ? false : true,
},
}),
]);
await db.auditEvent.create({
data: {
action: "task.complete",
entityId: task.id,
actorId: ctx.userId,
payload: { title: task.title, nextDue: next, via: ctx.via },
},
});
return { completion, nextDue: next };
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.9.1";
export const APP_VERSION = "0.13.0";