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

@@ -11,4 +11,7 @@ echo "Postgres is ready."
echo "Running Prisma migrations..." echo "Running Prisma migrations..."
./node_modules/.bin/prisma migrate deploy ./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 "$@" exec "$@"

View File

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

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]) @@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 // Garden — food forest plant registry
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -89,7 +123,9 @@ model Plant {
species String? // scientific name (optional) species String? // scientific name (optional)
variety String? // cultivar / variety name variety String? // cultivar / variety name
category PlantCategory 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 plantedAt DateTime? // when it went in the ground
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's") source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
notes String? // general notes about this plant notes String? // general notes about this plant
@@ -104,6 +140,7 @@ model Plant {
@@index([category]) @@index([category])
@@index([zone]) @@index([zone])
@@index([locationId])
@@index([active]) @@index([active])
@@index([groupId]) @@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());

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.9.0",
date: "2026-06-15", date: "2026-06-15",

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // 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";

View File

@@ -4,6 +4,7 @@
"execute-command": "/appdata/updater/update.sh", "execute-command": "/appdata/updater/update.sh",
"command-working-directory": "/appdata", "command-working-directory": "/appdata",
"response-message": "update started", "response-message": "update started",
"trigger-rule-mismatch-http-response-code": 403,
"trigger-rule": { "trigger-rule": {
"match": { "match": {
"type": "value", "type": "value",