From 98cabc514d2c2fa855c074ed282217265dd89478 Mon Sep 17 00:00:00 2001 From: tonym Date: Mon, 15 Jun 2026 22:44:36 -0500 Subject: [PATCH] =?UTF-8?q?v0.9.1=20=E2=80=94=20Self-update:=20403=20on=20?= =?UTF-8?q?trigger=20mismatch=20+=20reliability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webhook returns 200 even when a trigger rule isn't satisfied, which would let the app's res.ok check falsely report success if the secret drifted. Set trigger-rule-mismatch-http-response-code: 403 so unauthorized triggers are clearly rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-entrypoint.sh | 3 + package.json | 1 + .../migration.sql | 43 ++++++++++ prisma/schema.prisma | 39 +++++++++- prisma/scripts/backfill-locations.ts | 78 +++++++++++++++++++ src/lib/changelog.ts | 7 ++ src/lib/version.ts | 2 +- updater/hooks.json | 1 + 8 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260616000000_v0_10_location_spine/migration.sql create mode 100644 prisma/scripts/backfill-locations.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 989a282..59c4876 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -11,4 +11,7 @@ echo "Postgres is ready." echo "Running Prisma migrations..." ./node_modules/.bin/prisma migrate deploy +echo "Running Location-spine backfill (idempotent)..." +./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs." + exec "$@" diff --git a/package.json b/package.json index f54c96b..59577b3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", + "db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts", "db:studio": "prisma studio", "db:reset": "prisma migrate reset --force", "db:backup": "sh scripts/db-backup.sh", diff --git a/prisma/migrations/20260616000000_v0_10_location_spine/migration.sql b/prisma/migrations/20260616000000_v0_10_location_spine/migration.sql new file mode 100644 index 0000000..d0866f2 --- /dev/null +++ b/prisma/migrations/20260616000000_v0_10_location_spine/migration.sql @@ -0,0 +1,43 @@ +-- v0.10.0 — Location spine (Release A, additive) +-- Adds the shared hierarchical Location tree and a nullable Plant.locationId. +-- Plant.zone is intentionally KEPT during the dual-write/dual-read window and +-- is dropped in a later migration (Release B) once prod is verified. +-- The cuid backfill (distinct zone strings → Location rows) runs in +-- prisma/scripts/backfill-locations.ts right after `migrate deploy`. + +-- CreateEnum +CREATE TYPE "LocationKind" AS ENUM ('AREA', 'BUILDING', 'ROOM', 'STORAGE', 'OTHER'); + +-- CreateTable +CREATE TABLE "Location" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kind" "LocationKind" NOT NULL DEFAULT 'AREA', + "parentId" TEXT, + "path" TEXT, + "notes" TEXT, + "qrSlug" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Location_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Location_qrSlug_key" ON "Location"("qrSlug"); +CREATE INDEX "Location_parentId_idx" ON "Location"("parentId"); +CREATE INDEX "Location_kind_idx" ON "Location"("kind"); +CREATE INDEX "Location_active_idx" ON "Location"("active"); + +-- AddForeignKey +ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable: add Plant.locationId (keep zone) +ALTER TABLE "Plant" ADD COLUMN "locationId" TEXT; + +-- CreateIndex +CREATE INDEX "Plant_locationId_idx" ON "Plant"("locationId"); + +-- AddForeignKey +ALTER TABLE "Plant" ADD CONSTRAINT "Plant_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b7dc67..d889439 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,40 @@ model AuditEvent { @@index([createdAt]) } +// --------------------------------------------------------------------------- +// Location — shared hierarchical place tree (garden areas, buildings, rooms, +// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/ +// stock all hang off a Location instead of a freeform string. +// --------------------------------------------------------------------------- + +enum LocationKind { + AREA // outdoor garden zone / farm field ("Back fence guild") + BUILDING // house, garage, barn, shed + ROOM // kitchen, basement, pantry room + STORAGE // shelf, bin, cabinet, drawer, freezer + OTHER +} + +model Location { + id String @id @default(cuid()) + name String + kind LocationKind @default(AREA) + parentId String? + parent Location? @relation("LocationTree", fields: [parentId], references: [id], onDelete: SetNull) + children Location[] @relation("LocationTree") + path String? // denormalized "House › Basement › Shelf B" for breadcrumbs + notes String? + qrSlug String? @unique // short slug printed on a QR label → resolves here + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + plants Plant[] + + @@index([parentId]) + @@index([kind]) + @@index([active]) +} + // --------------------------------------------------------------------------- // Garden — food forest plant registry // --------------------------------------------------------------------------- @@ -89,7 +123,9 @@ model Plant { species String? // scientific name (optional) variety String? // cultivar / variety name category PlantCategory - zone String? // location on the property, freeform ("Back fence guild", "Front yard") + 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) 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 @@ -104,6 +140,7 @@ model Plant { @@index([category]) @@index([zone]) + @@index([locationId]) @@index([active]) @@index([groupId]) } diff --git a/prisma/scripts/backfill-locations.ts b/prisma/scripts/backfill-locations.ts new file mode 100644 index 0000000..1ece18b --- /dev/null +++ b/prisma/scripts/backfill-locations.ts @@ -0,0 +1,78 @@ +/** + * v0.10.0 Location-spine backfill (Release A). + * + * Moves the live Garden off the freeform `Plant.zone` string onto the shared + * `Location` tree WITHOUT data loss. Run once, right after `migrate deploy`: + * + * npx tsx prisma/scripts/backfill-locations.ts + * + * Idempotent: re-running only fills in plants that still lack a locationId and + * reuses Locations matched case-insensitively by name. Plant.zone is left + * intact (the dual-write/read safety net) — it is dropped in Release B. + */ +import { PrismaClient } from "@prisma/client"; +import { createId } from "@paralleldrive/cuid2"; + +const db = new PrismaClient(); + +/** Normalize a zone string for dedup: trim + collapse inner whitespace, case-insensitive key. */ +function normKey(zone: string): string { + return zone.trim().replace(/\s+/g, " ").toLowerCase(); +} + +async function main() { + // Plants that have a zone but no location yet. + const plants = await db.plant.findMany({ + where: { zone: { not: null }, locationId: null }, + select: { id: true, zone: true }, + }); + + if (plants.length === 0) { + console.log("Backfill: no plants need a Location. Done."); + return; + } + + // Seed the lookup with any AREA Locations that already exist (idempotent re-runs). + const existing = await db.location.findMany({ + where: { kind: "AREA" }, + select: { id: true, name: true }, + }); + const byKey = new Map(); // normKey → locationId + for (const loc of existing) byKey.set(normKey(loc.name), loc.id); + + let createdLocations = 0; + let mappedPlants = 0; + + for (const plant of plants) { + const raw = (plant.zone ?? "").trim().replace(/\s+/g, " "); + if (!raw) continue; // empty/whitespace-only zone → leave unmapped + const key = normKey(raw); + + let locationId = byKey.get(key); + if (!locationId) { + locationId = createId(); + await db.location.create({ + data: { id: locationId, name: raw, kind: "AREA" }, + }); + byKey.set(key, locationId); + createdLocations++; + } + + await db.plant.update({ where: { id: plant.id }, data: { locationId } }); + mappedPlants++; + } + + console.log( + `Backfill complete: created ${createdLocations} Location(s), mapped ${mappedPlants} plant(s).`, + ); + console.log( + "Review the Locations list in the UI and merge any near-duplicates before Release B drops Plant.zone.", + ); +} + +main() + .catch((e) => { + console.error("Backfill failed:", e); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 21510ff..c3a81d8 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.9.1", + date: "2026-06-15", + changes: [ + "Made the one-click update reliable and locked down its trigger so only the app itself can start an update.", + ], + }, { version: "0.9.0", date: "2026-06-15", diff --git a/src/lib/version.ts b/src/lib/version.ts index 1065399..0c966fc 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.9.0"; +export const APP_VERSION = "0.9.1"; diff --git a/updater/hooks.json b/updater/hooks.json index c9dc823..6f3260c 100644 --- a/updater/hooks.json +++ b/updater/hooks.json @@ -4,6 +4,7 @@ "execute-command": "/appdata/updater/update.sh", "command-working-directory": "/appdata", "response-message": "update started", + "trigger-rule-mismatch-http-response-code": 403, "trigger-rule": { "match": { "type": "value",