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.
221 lines
7.4 KiB
TypeScript
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);
|
|
});
|