- CatalogSearchOverlay: replace handleAddStub with real openAddToCollection/openAddToThread routing based on catalogSearchMode - ConfirmDialog + __root.tsx: swap t() for Trans component on deleteItemMessage, deleteCandidateMessage, pickWinnerMessage — fixes <bold> rendering as literal text - Biome format pass: fix 23 lint/format errors across scripts, services, tests - Planning: mark all UAT and verification gaps resolved for phases 07, 11, 16, 20, 21, 22, 24, 32, 34; close debug sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
// ── Dev Seed Runner ────────────────────────────────────────────────
|
|
// Clears dev data and re-seeds with fresh realistic data.
|
|
// Usage: bun run db:seed:dev
|
|
// Preserves real (non-dev) users. Safe to run repeatedly.
|
|
|
|
import { and, eq, like, sql } from "drizzle-orm";
|
|
import {
|
|
DEV_CATEGORIES,
|
|
DEV_GLOBAL_ITEMS,
|
|
DEV_MANUFACTURERS,
|
|
DEV_MARKET_PRICES,
|
|
DEV_SETTINGS,
|
|
DEV_SETUPS,
|
|
DEV_TAG_ASSIGNMENTS,
|
|
DEV_THREADS,
|
|
DEV_USER_ITEMS,
|
|
} from "./dev-seed-data.ts";
|
|
import { db } from "./index.ts";
|
|
import * as schema from "./schema.ts";
|
|
import { seedGlobalItems } from "./seed-global-items.ts";
|
|
|
|
type Db = typeof db;
|
|
|
|
async function clearDevData(database: Db) {
|
|
console.log("Clearing existing dev seed data...");
|
|
|
|
// Find dev user(s)
|
|
const devUsers = await database
|
|
.select({ id: schema.users.id })
|
|
.from(schema.users)
|
|
.where(like(schema.users.logtoSub, "dev-user%"));
|
|
|
|
for (const user of devUsers) {
|
|
// Delete in FK order: setup_items → setups, thread_candidates → threads, items, categories, settings, shares
|
|
await database
|
|
.delete(schema.setupItems)
|
|
.where(
|
|
sql`${schema.setupItems.setupId} IN (SELECT id FROM setups WHERE user_id = ${user.id})`,
|
|
);
|
|
await database
|
|
.delete(schema.shares)
|
|
.where(
|
|
sql`${schema.shares.setupId} IN (SELECT id FROM setups WHERE user_id = ${user.id})`,
|
|
);
|
|
await database
|
|
.delete(schema.setups)
|
|
.where(eq(schema.setups.userId, user.id));
|
|
await database
|
|
.delete(schema.threadCandidates)
|
|
.where(
|
|
sql`${schema.threadCandidates.threadId} IN (SELECT id FROM threads WHERE user_id = ${user.id})`,
|
|
);
|
|
await database
|
|
.delete(schema.threads)
|
|
.where(eq(schema.threads.userId, user.id));
|
|
await database
|
|
.delete(schema.communityPrices)
|
|
.where(eq(schema.communityPrices.userId, user.id));
|
|
await database.delete(schema.items).where(eq(schema.items.userId, user.id));
|
|
await database
|
|
.delete(schema.categories)
|
|
.where(eq(schema.categories.userId, user.id));
|
|
await database
|
|
.delete(schema.settings)
|
|
.where(eq(schema.settings.userId, user.id));
|
|
await database.delete(schema.users).where(eq(schema.users.id, user.id));
|
|
console.log(` Cleared dev user id=${user.id}`);
|
|
}
|
|
|
|
// Clear market prices (these are global, not user-scoped, but seeded by dev)
|
|
await database.delete(schema.marketPrices);
|
|
console.log(" Cleared market prices.");
|
|
|
|
// Global items and tags are shared — leave them (seedGlobalItems handles idempotency)
|
|
console.log("Dev data cleared.\n");
|
|
}
|
|
|
|
async function seedDevData(database: Db = db) {
|
|
// ── Clear previous dev data ────────────────────────────────────
|
|
await clearDevData(database);
|
|
|
|
try {
|
|
// ── 1. Seed global items, tags, and dev-specific manufacturers ─
|
|
await seedGlobalItems(database);
|
|
for (const m of DEV_MANUFACTURERS) {
|
|
await database
|
|
.insert(schema.manufacturers)
|
|
.values(m)
|
|
.onConflictDoNothing();
|
|
}
|
|
console.log(" Global items, tags, and manufacturers seeded.");
|
|
|
|
// ── 2. Insert dev user ─────────────────────────────────────
|
|
const [user] = await database
|
|
.insert(schema.users)
|
|
.values({
|
|
logtoSub: "dev-user-seed",
|
|
displayName: "Dev User",
|
|
bio: "Bikepacking enthusiast and gear nerd. Always optimizing the kit.",
|
|
})
|
|
.returning();
|
|
if (!user) throw new Error("Failed to insert dev user");
|
|
const userId = user.id;
|
|
console.log(` Dev user created (id=${userId}).`);
|
|
|
|
// ── 3. Insert categories ───────────────────────────────────
|
|
const insertedCategories = await database
|
|
.insert(schema.categories)
|
|
.values(
|
|
DEV_CATEGORIES.map((c) => ({
|
|
name: c.name,
|
|
icon: c.icon,
|
|
userId,
|
|
})),
|
|
)
|
|
.returning();
|
|
|
|
const categoryByName = new Map<string, number>();
|
|
for (const cat of insertedCategories) {
|
|
categoryByName.set(cat.name, cat.id);
|
|
}
|
|
console.log(` ${insertedCategories.length} categories created.`);
|
|
|
|
// ── 4. Look up tag IDs ─────────────────────────────────────
|
|
const allTags = await database.select().from(schema.tags);
|
|
const tagNameToId = new Map<string, number>();
|
|
for (const tag of allTags) {
|
|
tagNameToId.set(tag.name, tag.id);
|
|
}
|
|
|
|
// ── 5. Insert global items and tag assignments ─────────────
|
|
// DEV_GLOBAL_ITEMS may overlap with seed-global-items.json entries.
|
|
// Insert only items that don't already exist (by manufacturerId+model).
|
|
const allManufacturers = await database.select().from(schema.manufacturers);
|
|
const mfBySlug = new Map(allManufacturers.map((m) => [m.slug, m.id]));
|
|
|
|
const existingGlobalItems = await database
|
|
.select()
|
|
.from(schema.globalItems);
|
|
const existingGlobalItemMap = new Map<string, number>();
|
|
for (const gi of existingGlobalItems) {
|
|
existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id);
|
|
}
|
|
|
|
const globalItemIds: number[] = [];
|
|
let newGlobalCount = 0;
|
|
|
|
for (const item of DEV_GLOBAL_ITEMS) {
|
|
const mfId = mfBySlug.get(item.manufacturerSlug);
|
|
if (!mfId) {
|
|
console.warn(
|
|
` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`,
|
|
);
|
|
globalItemIds.push(0); // placeholder to keep index alignment
|
|
continue;
|
|
}
|
|
const key = `${mfId}::${item.model}`;
|
|
const existingId = existingGlobalItemMap.get(key);
|
|
if (existingId) {
|
|
globalItemIds.push(existingId);
|
|
} else {
|
|
const [inserted] = await database
|
|
.insert(schema.globalItems)
|
|
.values({
|
|
manufacturerId: mfId,
|
|
model: item.model,
|
|
category: item.category,
|
|
weightGrams: item.weightGrams,
|
|
priceCents: item.priceCents,
|
|
description: item.description,
|
|
})
|
|
.returning();
|
|
if (!inserted)
|
|
throw new Error(
|
|
`Failed to insert global item: ${item.manufacturerSlug} ${item.model}`,
|
|
);
|
|
globalItemIds.push(inserted.id);
|
|
newGlobalCount++;
|
|
}
|
|
}
|
|
console.log(
|
|
` ${globalItemIds.length} global items mapped (${newGlobalCount} new).`,
|
|
);
|
|
|
|
// Insert tag assignments
|
|
let tagAssignmentCount = 0;
|
|
for (const assignment of DEV_TAG_ASSIGNMENTS) {
|
|
const giId = globalItemIds[assignment.globalItemIndex];
|
|
if (!giId) continue;
|
|
for (const tagName of assignment.tagNames) {
|
|
const tagId = tagNameToId.get(tagName);
|
|
if (!tagId) continue;
|
|
// Skip if already exists
|
|
const existingLink = await database
|
|
.select()
|
|
.from(schema.globalItemTags)
|
|
.where(
|
|
and(
|
|
eq(schema.globalItemTags.globalItemId, giId),
|
|
eq(schema.globalItemTags.tagId, tagId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existingLink.length === 0) {
|
|
await database
|
|
.insert(schema.globalItemTags)
|
|
.values({ globalItemId: giId, tagId });
|
|
tagAssignmentCount++;
|
|
}
|
|
}
|
|
}
|
|
console.log(` ${tagAssignmentCount} tag assignments created.`);
|
|
|
|
// ── 6. Insert user items ───────────────────────────────────
|
|
const userItemValues = DEV_USER_ITEMS.map((item) => ({
|
|
name: item.name,
|
|
weightGrams: item.weightGrams,
|
|
priceCents: item.priceCents,
|
|
categoryId: categoryByName.get(item.categoryName)!,
|
|
userId,
|
|
notes: item.notes,
|
|
quantity: item.quantity,
|
|
globalItemId:
|
|
item.globalItemIndex !== null
|
|
? globalItemIds[item.globalItemIndex]
|
|
: null,
|
|
purchasePriceCents: item.purchasePriceCents,
|
|
}));
|
|
|
|
const insertedItems = await database
|
|
.insert(schema.items)
|
|
.values(userItemValues)
|
|
.returning();
|
|
console.log(` ${insertedItems.length} user items created.`);
|
|
|
|
// ── 7. Insert threads ──────────────────────────────────────
|
|
const threadResults: Array<{
|
|
threadId: number;
|
|
threadDef: (typeof DEV_THREADS)[number];
|
|
}> = [];
|
|
|
|
for (const threadDef of DEV_THREADS) {
|
|
const catId = categoryByName.get(threadDef.categoryName);
|
|
if (!catId) continue;
|
|
|
|
const [thread] = await database
|
|
.insert(schema.threads)
|
|
.values({
|
|
name: threadDef.name,
|
|
status: threadDef.status,
|
|
categoryId: catId,
|
|
userId,
|
|
})
|
|
.returning();
|
|
if (!thread)
|
|
throw new Error(`Failed to insert thread: ${threadDef.name}`);
|
|
threadResults.push({ threadId: thread.id, threadDef });
|
|
}
|
|
console.log(` ${threadResults.length} threads created.`);
|
|
|
|
// ── 8. Insert thread candidates ────────────────────────────
|
|
let candidateCount = 0;
|
|
for (const { threadId, threadDef } of threadResults) {
|
|
const catId = categoryByName.get(threadDef.categoryName)!;
|
|
|
|
const insertedCandidates = [];
|
|
for (const cand of threadDef.candidates) {
|
|
const [inserted] = await database
|
|
.insert(schema.threadCandidates)
|
|
.values({
|
|
threadId,
|
|
name: cand.name,
|
|
weightGrams: cand.weightGrams,
|
|
priceCents: cand.priceCents,
|
|
categoryId: catId,
|
|
status: cand.status,
|
|
pros: cand.pros,
|
|
cons: cand.cons,
|
|
notes: cand.notes,
|
|
sortOrder: cand.sortOrder,
|
|
globalItemId:
|
|
cand.globalItemIndex !== null
|
|
? globalItemIds[cand.globalItemIndex]
|
|
: null,
|
|
})
|
|
.returning();
|
|
insertedCandidates.push(inserted);
|
|
candidateCount++;
|
|
}
|
|
|
|
// Set resolvedCandidateId for resolved threads
|
|
if (
|
|
"resolvedCandidateIndex" in threadDef &&
|
|
threadDef.resolvedCandidateIndex !== undefined
|
|
) {
|
|
const winnerCandidate =
|
|
insertedCandidates[threadDef.resolvedCandidateIndex];
|
|
if (winnerCandidate) {
|
|
await database
|
|
.update(schema.threads)
|
|
.set({ resolvedCandidateId: winnerCandidate.id })
|
|
.where(eq(schema.threads.id, threadId));
|
|
}
|
|
}
|
|
}
|
|
console.log(` ${candidateCount} thread candidates created.`);
|
|
|
|
// ── 9. Insert setups ───────────────────────────────────────
|
|
const setupResults: Array<{
|
|
setupId: number;
|
|
setupDef: (typeof DEV_SETUPS)[number];
|
|
}> = [];
|
|
|
|
for (const setupDef of DEV_SETUPS) {
|
|
const [setup] = await database
|
|
.insert(schema.setups)
|
|
.values({
|
|
name: setupDef.name,
|
|
userId,
|
|
visibility: setupDef.visibility,
|
|
})
|
|
.returning();
|
|
if (!setup) throw new Error(`Failed to insert setup: ${setupDef.name}`);
|
|
setupResults.push({ setupId: setup.id, setupDef });
|
|
}
|
|
console.log(` ${setupResults.length} setups created.`);
|
|
|
|
// ── 10. Insert setup items ─────────────────────────────────
|
|
let setupItemCount = 0;
|
|
for (const { setupId, setupDef } of setupResults) {
|
|
for (const si of setupDef.items) {
|
|
const userItem = insertedItems[si.userItemIndex];
|
|
if (!userItem) continue;
|
|
await database.insert(schema.setupItems).values({
|
|
setupId,
|
|
itemId: userItem.id,
|
|
classification: si.classification,
|
|
});
|
|
setupItemCount++;
|
|
}
|
|
}
|
|
console.log(` ${setupItemCount} setup items created.`);
|
|
|
|
// ── 11. Insert settings ────────────────────────────────────
|
|
for (const setting of DEV_SETTINGS) {
|
|
await database.insert(schema.settings).values({
|
|
userId,
|
|
key: setting.key,
|
|
value: setting.value,
|
|
});
|
|
}
|
|
console.log(` ${DEV_SETTINGS.length} settings created.`);
|
|
|
|
// ── 12. Insert market prices ───────────────────────────────
|
|
let marketPriceCount = 0;
|
|
for (const mp of DEV_MARKET_PRICES) {
|
|
const giId = globalItemIds[mp.globalItemIndex];
|
|
if (!giId) continue;
|
|
await database.insert(schema.marketPrices).values({
|
|
globalItemId: giId,
|
|
market: mp.market,
|
|
currency: mp.currency,
|
|
priceCents: mp.priceCents,
|
|
source: mp.source,
|
|
});
|
|
marketPriceCount++;
|
|
}
|
|
console.log(` ${marketPriceCount} market prices created.`);
|
|
|
|
// ── 13. Insert community prices ────────────────────────────
|
|
// Seed a few community prices from the dev user for items they own
|
|
const ownedGlobalItemIds = insertedItems
|
|
.filter((i) => i.globalItemId !== null)
|
|
.map((i) => i.globalItemId as number);
|
|
|
|
let communityPriceCount = 0;
|
|
for (const giId of ownedGlobalItemIds.slice(0, 5)) {
|
|
const item = insertedItems.find((i) => i.globalItemId === giId);
|
|
if (!item) continue;
|
|
await database.insert(schema.communityPrices).values({
|
|
globalItemId: giId,
|
|
userId,
|
|
market: "EU",
|
|
currency: "EUR",
|
|
priceCents: item.priceCents
|
|
? Math.round(item.priceCents * 0.85)
|
|
: 10000,
|
|
priceDate: new Date("2026-03-15"),
|
|
sourceType: "purchased",
|
|
});
|
|
communityPriceCount++;
|
|
}
|
|
console.log(` ${communityPriceCount} community prices created.`);
|
|
|
|
// ── Summary ────────────────────────────────────────────────
|
|
console.log(
|
|
`\nDev seed complete: ${globalItemIds.length} global items, ${allTags.length} tags, ${insertedItems.length} user items, ${threadResults.length} threads, ${setupResults.length} setups, ${marketPriceCount} market prices`,
|
|
);
|
|
} catch (err) {
|
|
console.error("Seed failed:", err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── Entry point ────────────────────────────────────────────────────
|
|
|
|
seedDevData()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
console.error("Seed failed:", err);
|
|
process.exit(1);
|
|
});
|