v0.9.1 — Self-update: 403 on trigger mismatch + reliability

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:44:36 -05:00
parent 4e02339f59
commit 98cabc514d
8 changed files with 172 additions and 2 deletions

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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<string, string>(); // 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());