Gives the operator two new controls on a collection-pool stop, and fixes the audit feed they write into. Back-to-pending (`items/[id]/back-to-pending`) A stop has to come back when the driver can't take it — no-show, wrong date, supplier pushed the delivery — or when the supplier confirmation was premature. A row can be assigned to a driver and/or marked ready at the same time, so the route clears both in one write rather than making the operator unwind them in the wrong order. Collection date (`items/[id]/collection-date`) `order_items.collection_date` was a PB date field that existed but nothing read or wrote it. On a PENDING stop it is a forecast — the supplier said goods land on the 20th, so the stop stays un-assignable because promising a driver a pickup for goods that don't exist yet would be a lie. On a READY stop it is the confirmed collection day. Notes feed (`src/lib/note-feed.ts`) `notes` on order_items / stock_purchase_items is NOT free text — it is a JSON array of NoteEntry objects, and every consumer parses it as one. The assign route's "mark ready" path wrote a plain string into it, which silently destroys the entire history: the next parse falls back to an empty feed. All writers now go through `appendNote` with the row's current value. Lives in `src/lib` rather than a page view model because both the client pages and the server routes need it, and an API route must never import from `app/(app)`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
/**
|
|
* Add a field to a LIVE PocketBase collection, idempotently.
|
|
*
|
|
* Exists because this repo's `pb_schema (1).json` is a stale export:
|
|
* it lists fields the database does not have (and omits whole
|
|
* collections). PocketBase SILENTLY DROPS unknown fields on update — no
|
|
* error, no failed response — so code written against a phantom field
|
|
* appears to work while storing nothing.
|
|
*
|
|
* SAFETY:
|
|
* - Prints the current state and exits 0 if the field already exists.
|
|
* - Sends the FULL `fields[]` array back with one addition.
|
|
* `PATCH /api/collections/:c/fields/:id` 404s on PocketBase, so the
|
|
* collection-level PATCH is the supported route.
|
|
* - Re-reads afterwards and diffs field-by-field, failing loudly if
|
|
* anything other than the addition changed.
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/add-collection-field.ts order_items collection_date date
|
|
* npx tsx scripts/add-collection-field.ts order_items pool_entered_at date
|
|
*/
|
|
import { config } from "dotenv";
|
|
|
|
config({ path: ".env.local" });
|
|
config({ path: ".env" });
|
|
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL || "";
|
|
const email = process.env.PB_ADMIN_EMAIL || "";
|
|
const password = process.env.PB_ADMIN_PASSWORD || "";
|
|
|
|
type Field = Record<string, unknown> & { name: string; type: string };
|
|
|
|
const [, , collectionArg, nameArg, typeArg] = process.argv;
|
|
|
|
if (!collectionArg || !nameArg || !typeArg) {
|
|
console.error(
|
|
"usage: npx tsx scripts/add-collection-field.ts <collection> <field_name> <type>",
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
async function auth(): Promise<string> {
|
|
const res = await fetch(`${url}/api/collections/_superusers/auth-with-password`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ identity: email, password }),
|
|
});
|
|
if (!res.ok) throw new Error(`auth failed: ${res.status} ${await res.text()}`);
|
|
return ((await res.json()) as { token: string }).token;
|
|
}
|
|
|
|
async function getCollection(token: string, name: string) {
|
|
const res = await fetch(`${url}/api/collections/${name}`, {
|
|
headers: { Authorization: token },
|
|
});
|
|
if (!res.ok) throw new Error(`get ${name} failed: ${res.status} ${await res.text()}`);
|
|
return (await res.json()) as { fields: Field[] };
|
|
}
|
|
|
|
async function main() {
|
|
if (!url || !email || !password) throw new Error("missing PB env vars");
|
|
|
|
const token = await auth();
|
|
const before = await getCollection(token, collectionArg);
|
|
|
|
console.log(`${collectionArg}: ${before.fields.length} fields`);
|
|
const existing = before.fields.find((f) => f.name === nameArg);
|
|
if (existing) {
|
|
console.log(`\n${nameArg} already exists (${existing.type}) — nothing to do.`);
|
|
return;
|
|
}
|
|
|
|
// Copy the shape of an existing field of the same type so the new one
|
|
// matches its siblings (PB stores per-field config beyond type/name).
|
|
const template = before.fields.find((f) => f.type === typeArg);
|
|
const newField: Field = {
|
|
...(template ?? {}),
|
|
id: `fld_${nameArg.replace(/[^a-z0-9]/gi, "_").slice(0, 11)}`,
|
|
name: nameArg,
|
|
type: typeArg,
|
|
required: false,
|
|
presentable: false,
|
|
hidden: false,
|
|
system: false,
|
|
};
|
|
|
|
const fields = [...before.fields, newField];
|
|
|
|
const patchRes = await fetch(`${url}/api/collections/${collectionArg}`, {
|
|
method: "PATCH",
|
|
headers: { Authorization: token, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fields }),
|
|
});
|
|
if (!patchRes.ok) {
|
|
throw new Error(`patch failed: ${patchRes.status} ${await patchRes.text()}`);
|
|
}
|
|
|
|
const after = await getCollection(token, collectionArg);
|
|
const added = after.fields.filter((f) => !before.fields.some((b) => b.name === f.name));
|
|
const removed = before.fields.filter((f) => !after.fields.some((a) => a.name === f.name));
|
|
const changed = after.fields.filter((a) => {
|
|
const b = before.fields.find((x) => x.name === a.name);
|
|
return b && JSON.stringify(b) !== JSON.stringify(a);
|
|
});
|
|
|
|
console.log(`\nadded: ${added.map((f) => `${f.name} (${f.type})`).join(", ") || "(none)"}`);
|
|
console.log(`removed: ${removed.map((f) => f.name).join(", ") || "(none)"}`);
|
|
console.log(`changed: ${changed.map((f) => f.name).join(", ") || "(none)"}`);
|
|
|
|
if (added.length !== 1 || removed.length > 0 || changed.length > 0) {
|
|
throw new Error("UNEXPECTED schema drift — inspect before proceeding.");
|
|
}
|
|
console.log(`\nOK: ${nameArg} added, nothing else moved.`);
|
|
}
|
|
|
|
void main().catch((err: unknown) => {
|
|
console.error(`FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exit(1);
|
|
});
|