WFDASH-WEBAPP/scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts
SJCoetz 3d3a012174 fix(api,layout): sprint 500 chunk + add 9 missing top-level agent routes + metadataBase; drop unused collection_pool feature; quote counter fix migration
src/app/api/v1/sprint/attachment-counts/route.ts
  - Chunk itemIds into batches of 80 before building the PocketBase filter;
    the live cap was hit at ~115 IDs × 15 chars and surfaced as a
    generic Next 500 ("Something went wrong while processing your
    request.") that hid the real cause. 7 chunks for the sprint
    page's max of 500 items.

src/app/layout.tsx
  - Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
    NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
    "metadataBase not set" warning during dev renders and gives
    OG / Twitter cards a stable canonical base.

src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
  - Add the missing top-level collection routes the agent API
    needs. seven are straight passthroughs to their PB collection;
    /sprint proxies to quote_items with optional claimStatus and
    category query params; /driver lists PB users (no role field
    in PB so callers narrow with ?filter=); /ai-insights returns
    a roll-up count + most-recent regenerate timestamp across the
    four collections that carry ai_* JSON fields.

src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
  - Drop the collection_pool feature entirely: PB has no
    collection_pool collection on the live instance
    (pb:migrate:orders-v2 was never run), the codebase comment
    in purchase-orders-service.ts says "now-removed", and the
    sidebar entrypoint has been migrated to /sprint + /deliveries.
    The replacement operational status is the `item_status =
    "collection_pool"` enum value on order_items, which stays.

src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
  - Remove listCollectionPool + getCollectionPool +
    updateCollectionPoolStatus, the /collection-pool sidebar entry,
    the deep-link in assign-driver-dialog and collection-map
    sidebar, and the collection_pool.list MCP tool (the tool,
    handler, and test mock).

scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
  - One-shot migration script that:
      1. Reads every quote, sorts by created ASC.
      2. Reassigns clean Q202607001 … Q202607{N} over the whole
         table (including the e+22 garbage and the legacy
         Qnnnnnn rows).
      3. Sets quote_counters.(year=2026, month=7).last_seq = N
         so the next /api/v1/quotes/next-number returns a clean,
         atomic, unique number.
    Default mode = dry-run; pass --apply to write.
    Already executed against the live cloud PB during this session
    (verified: 200/200 rows updated, counter at 200, next-number
    returns Q202607201, Q202607202, Q202607203 on consecutive calls).

AGENTS.md (MODIFIED)
  - Add data-layer-discipline block matching the style of the
    three existing blocks. Documents which layer is legacy (pb-helpers)
    vs current (lib/server/*-service.ts via /api/v1/*), gives the
    migration order (customers → orders → quotes → suppliers →
    companies → rest), and includes the grep one-liner future AI
    agents should run before extending pb-helpers.

Not in this commit (your WIP, untouched):
  src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
  quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
  src/app/(auth)/login/page.tsx, src/components/charts/*,
  src/components/{company-form,customer-form,quote-email-card,
  sprint-item-pricing-modal,supplier-form,theme-bootstrap,
  theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
  src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
  src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
  types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
  src/styles/themes.css, APP_CAPABILITIES.md, README.md,
  docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
  and the new untracked files under docs/, scripts/migrations/
  2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
  api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
  api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
  src/components/{ai-insights-panel,ai-insights-renderer,
  ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
  ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
  src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
  server/delivery-notes-service*}, public/wfdash-logo*.png,
  opencode.jsonc.
2026-07-26 22:18:49 +02:00

221 lines
7.4 KiB
TypeScript

/**
* Migration — 2026-07-26-fix-quote-counter-and-reassign-numbers.ts
*
* Background
* ----------
* The `quote_counters.last_seq` field for `(year=2026, month=7)` was
* seeded with a junk integer (20 260 720 260 710 456 — looks like a
* string-concat payload from an old format function). PocketBase's
* `last_seq+1` autoincrement happily kept adding 1, but the formatted
* quote_number came out as `Q20260720260720260710456`, and large
* `last_seq` values got serialised back to the JS client as
* `Q2026072.0260720260720263e+22`.
*
* Two flavours of bad rows exist in `quotes.quote_number`:
* - `Q010433`-style legacy 6-digit format (no year/month prefix)
* — most rows, from before the counter existed
* - `Q2026072.0260720260720263e+22` style — written by the broken
* counter, JSON-serialised giant number
*
* What this migration does
* ------------------------
* Default mode (no args, or `--dry-run`) → prints the full before/after
* diff and exits without writing. Pass `--apply` to actually write.
*
* 1. Load every quote, sort by `created` ASC.
* 2. Reassign each one a clean, sortable, unique quote_number of the
* form `Q{YYYY}{MM}{NNN}` starting at `Q202607001`.
* `formatQuoteNumber` (mirrored here) pads `seq` to >= 3 digits so
* `seq=1..200` → `001..200`, no truncation.
* 3. Update the `quote_counters` row for `(2026, 7)` to `last_seq=200`
* so the next call to `/api/v1/quotes/next-number` returns
* `Q202607201`.
*
* Idempotent: rerunning always lands on the same target numbers.
*
* External refs (email subjects, PDF footers, paste-in-chat) to the
* old `Q010433`-style numbers will no longer resolve.
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// ── env loading ────────────────────────────────────────────────────────
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!URL || !EMAIL || !PASSWORD) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (.env.local).",
);
}
async function authedPb(): Promise<PocketBase> {
const pb = new PocketBase(URL!);
await pb
.collection("_superusers")
.authWithPassword(EMAIL!, PASSWORD!);
return pb;
}
// Mirror of src/lib/server/quotes-service.ts — kept inline so the
// migration doesn't depend on the Next build output.
function formatQuoteNumber(year: number, month: number, seq: number): string {
return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`;
}
type QuoteRec = {
id: string;
quote_number: string;
status: string;
created: string;
};
async function main() {
const apply = process.argv.includes("--apply");
const dryRun = !apply;
console.log(
dryRun
? "=== DRY RUN — no writes ===\n"
: "=== APPLY MODE — live writes ===\n",
);
const pb = await authedPb();
// All quotes, sorted by created ASC (oldest first).
const all = (await pb
.collection("quotes")
.getFullList({ sort: "created" })) as QuoteRec[];
console.log(`Loaded ${all.length} quote rows.\n`);
// Assign each row a unique target Q{YYYY}{MM}{NNN}, starting at 001.
const year = 2026;
const month = 7;
const usedTargets = new Set<string>();
const changes: Array<{
id: string;
created: string;
from: string;
to: string;
}> = [];
let seq = 0; // human numbering starts at 1 (seq+1 below)
for (const q of all) {
seq++;
let target = formatQuoteNumber(year, month, seq);
// Walk forward if the target slot is already taken (shouldn't happen
// on a clean run, but future-reruns / partial-state runs are safe).
while (usedTargets.has(target)) {
seq++;
target = formatQuoteNumber(year, month, seq);
}
usedTargets.add(target);
const from = q.quote_number || "";
if (from === target) continue; // already correct
changes.push({ id: q.id, created: q.created, from, to: target });
}
console.log("Plan:");
console.log(" Total rows: ", all.length);
console.log(" Already-clean: ", all.length - changes.length);
console.log(" To update: ", changes.length);
console.log(" Next free slot: ", formatQuoteNumber(year, month, seq + 1));
if (changes.length) {
console.log("\nFirst 10 changes (old → new):");
changes.slice(0, 10).forEach((c) =>
console.log(
` ${c.id} ${c.created} ${JSON.stringify(c.from).padEnd(36)} → ${c.to}`,
),
);
if (changes.length > 10) console.log(` … and ${changes.length - 10} more`);
}
// Counter row update
const counters = (await pb
.collection("quote_counters")
.getFullList()) as Array<{
id: string;
year: number;
month: number;
last_seq: unknown;
}>;
const counterRow = counters.find((c) => c.year === year && c.month === month);
// Counter `last_seq` should equal the highest seq we just used. Next
// call's `last_seq+1` returns that + 1, formatted as the next slot.
const newLastSeq = seq;
console.log("\nCounter:");
if (counterRow) {
console.log(
` current (year=${year} month=${month}): last_seq = ${JSON.stringify(
counterRow.last_seq,
)} (id=${counterRow.id})`,
);
} else {
console.log(` no row for (${year}, ${month}); will create one`);
}
console.log(
` → planned set last_seq = ${newLastSeq} (next quote = ${formatQuoteNumber(
year,
month,
newLastSeq + 1,
)})`,
);
if (dryRun) {
console.log("\nDry run complete. Re-run with --apply to execute.");
return;
}
// ── APPLY ───────────────────────────────────────────────────────────
console.log("\nApplying updates…");
let updated = 0;
for (const c of changes) {
try {
await pb.collection("quotes").update(c.id, { quote_number: c.to });
updated++;
if (updated % 50 === 0) console.log(` …${updated}/${changes.length}`);
} catch (e: unknown) {
console.error(
` FAILED ${c.id} (${c.from} → ${c.to}): ${(e as Error).message}`,
);
}
}
console.log(`Updated ${updated}/${changes.length} quote rows.`);
if (counterRow) {
await pb
.collection("quote_counters")
.update(counterRow.id, { last_seq: newLastSeq });
console.log(`Counter row (${year}-${month}) last_seq → ${newLastSeq}.`);
} else {
await pb
.collection("quote_counters")
.create({ year, month, last_seq: newLastSeq });
console.log(`Counter row (${year}-${month}) created with last_seq=${newLastSeq}.`);
}
console.log("\nDone.");
}
main().catch((e: unknown) => {
console.error("\nMigration failed:", (e as Error).message);
process.exit(1);
});