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:
@@ -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 "$@"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
78
prisma/scripts/backfill-locations.ts
Normal file
78
prisma/scripts/backfill-locations.ts
Normal 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());
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user