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..."
|
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 "$@"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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])
|
@@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])
|
||||||
}
|
}
|
||||||
|
|||||||
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[] = [
|
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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user