Compare commits
6 Commits
6f8fe9a798
...
777569a21a
| Author | SHA1 | Date | |
|---|---|---|---|
| 777569a21a | |||
| b0118d3fec | |||
| 0904790dca | |||
| c5bf108cb9 | |||
| cdb21ed58a | |||
| 9c0312676a |
108
CLAUDE.md
108
CLAUDE.md
@@ -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
77
README.md
Normal 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`.
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal file
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal 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;
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
@@ -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 1–12
|
||||
bloomEnd Int? // month 1–12
|
||||
harvestStart Int? // month 1–12
|
||||
harvestEnd Int? // month 1–12
|
||||
toxicToPets Boolean? // null = unknown
|
||||
edibleParts String? // "Fruit, young leaves"
|
||||
spacing String? // "10–15 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? // 1–12: 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])
|
||||
}
|
||||
|
||||
23
prisma/scripts/backfill-plant-types.ts
Normal file
23
prisma/scripts/backfill-plant-types.ts
Normal 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());
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
77
src/app/(dashboard)/garden/types/page.tsx
Normal 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 & 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'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>
|
||||
);
|
||||
}
|
||||
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/(dashboard)/inventory/page.tsx
Normal file
62
src/app/(dashboard)/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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 />;
|
||||
}
|
||||
|
||||
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal file
18
src/app/(dashboard)/settings/tokens/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/app/api/account/change-password/route.ts
Normal file
57
src/app/api/account/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/account/tokens/[id]/route.ts
Normal file
29
src/app/api/account/tokens/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
75
src/app/api/account/tokens/route.ts
Normal file
75
src/app/api/account/tokens/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
71
src/app/api/locations/[id]/log/route.ts
Normal file
71
src/app/api/locations/[id]/log/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/locations/[id]/route.ts
Normal file
106
src/app/api/locations/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
78
src/app/api/locations/route.ts
Normal file
78
src/app/api/locations/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
104
src/app/api/plant-types/[id]/route.ts
Normal file
104
src/app/api/plant-types/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
91
src/app/api/plant-types/route.ts
Normal file
91
src/app/api/plant-types/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
15
src/app/api/v1/items/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/app/api/v1/items/[id]/route.ts
Normal file
36
src/app/api/v1/items/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/items/route.ts
Normal file
34
src/app/api/v1/items/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/app/api/v1/labels/route.ts
Normal file
37
src/app/api/v1/labels/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/locations/route.ts
Normal file
13
src/app/api/v1/locations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plant-types/route.ts
Normal file
14
src/app/api/v1/plant-types/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal file
16
src/app/api/v1/plants/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/plants/[id]/route.ts
Normal file
13
src/app/api/v1/plants/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/app/api/v1/plants/route.ts
Normal file
14
src/app/api/v1/plants/route.ts
Normal 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
22
src/app/api/v1/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal file
15
src/app/api/v1/tasks/[id]/complete/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/app/api/v1/tasks/route.ts
Normal file
25
src/app/api/v1/tasks/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/app/change-password/page.tsx
Normal file
13
src/app/change-password/page.tsx
Normal 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} />;
|
||||
}
|
||||
116
src/components/account/change-password-form.tsx
Normal file
116
src/components/account/change-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
58
src/components/garden/delete-plant-type-button.tsx
Normal file
58
src/components/garden/delete-plant-type-button.tsx
Normal 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 “{name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the plant-type reference. Your individual plantings stay put —
|
||||
they just won'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
262
src/components/garden/plant-type-dialog.tsx
Normal file
262
src/components/garden/plant-type-dialog.tsx
Normal 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 1–12 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="10–15 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
src/components/inventory/add-item-log-button.tsx
Normal file
78
src/components/inventory/add-item-log-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/components/inventory/attachment-upload.tsx
Normal file
35
src/components/inventory/attachment-upload.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
src/components/inventory/delete-item-button.tsx
Normal file
57
src/components/inventory/delete-item-button.tsx
Normal 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 “{name}”?</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
src/components/inventory/inventory-grid.tsx
Normal file
145
src/components/inventory/inventory-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/inventory/item-dialog.tsx
Normal file
252
src/components/inventory/item-dialog.tsx
Normal 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>} />
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
217
src/components/settings/api-tokens-panel.tsx
Normal file
217
src/components/settings/api-tokens-panel.tsx
Normal 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'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 & 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>
|
||||
);
|
||||
}
|
||||
115
src/components/settings/backup-panel.tsx
Normal file
115
src/components/settings/backup-panel.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
53
src/lib/api/authenticate.test.ts
Normal file
53
src/lib/api/authenticate.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
97
src/lib/api/authenticate.ts
Normal file
97
src/lib/api/authenticate.ts
Normal 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
61
src/lib/api/schemas.ts
Normal 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
22
src/lib/api/scopes.ts
Normal 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 },
|
||||
];
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
src/lib/garden/plant-types.ts
Normal file
63
src/lib/garden/plant-types.ts
Normal 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 };
|
||||
}
|
||||
26
src/lib/inventory/item-fields.ts
Normal file
26
src/lib/inventory/item-fields.ts
Normal 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];
|
||||
46
src/lib/inventory/value.test.ts
Normal file
46
src/lib/inventory/value.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
src/lib/inventory/value.ts
Normal file
39
src/lib/inventory/value.ts
Normal 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
40
src/lib/locations/db.ts
Normal 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);
|
||||
}
|
||||
108
src/lib/locations/tree.test.ts
Normal file
108
src/lib/locations/tree.test.ts
Normal 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
123
src/lib/locations/tree.ts
Normal 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
173
src/lib/services/items.ts
Normal 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;
|
||||
}
|
||||
10
src/lib/services/locations.ts
Normal file
10
src/lib/services/locations.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
12
src/lib/services/plant-types.ts
Normal file
12
src/lib/services/plant-types.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
57
src/lib/services/plants.ts
Normal file
57
src/lib/services/plants.ts
Normal 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
67
src/lib/services/tasks.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user