/* ═══════════════════════════════════════════════════════════════ SEED Reads the two static data modules and fills the database from them. Run once to make the move, and re-runnable after you tweak the source files. cd /root/NGU-Web.v1.3-sqlite/server DB_PATH=./dev.db node src/seed.js Run it from the repo, not from /srv/ngu-api — the deployed copy has no src/data to read. ⚠ It clears every content table first, so anything typed straight into the database is lost. Feedback is never touched. Section presentation (title, accent, background, defaultView) is NOT imported. Retreats.jsx owns that; only the ids come across, so section_id has something real to reference. Three things it deliberately does NOT do, each flagged in the warnings at the end rather than guessed at: dates "March/April 2026" isn't parseable, and half-right dates are worse than none. starts_on stays null and the explicit status carries the upcoming/past split exactly as it does today. partners the five partner events are placeholders with no organization behind them, so host_org_id is null. leads "Chapter lead name" is not a person. Inventing a people row from a placeholder string would put a fake name on the site. ═══════════════════════════════════════════════════════════════ */ import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { openDatabase, migrate, tx } from "./db.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const DB_PATH = process.env.DB_PATH ?? "./dev.db"; const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; // The root organization. Every national retreat hangs off this, and // it's what makes the org_logo fallback work uniformly. const NGU = { id: "ngu", name: "Next Generation of Unity", short_name: "NGU", color: "#138ba0", logo: "ngu-logo-white-bg.svg", }; const warnings = []; const warn = (message) => warnings.push(message); /* ── Load the source modules ───────────────────────────────── */ async function load(relative) { const path = resolve(HERE, relative); try { return await import(pathToFileURL(path).href); } catch (err) { console.error(`\nCould not read ${path}`); console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n"); throw err; } } const eventsModule = await load(EVENTS_MODULE); const chaptersModule = await load(CHAPTERS_MODULE); const eventsData = eventsModule.default; const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule; /* ── Helpers ───────────────────────────────────────────────── */ const isStateCode = (code) => Boolean(code) && code !== "CANADA" && code in STATE_NAMES; const opposite = (edge) => (edge === "top" ? "bottom" : "top"); const instagramUrl = (handle) => `https://instagram.com/${String(handle).replace(/^@/, "")}`; // "Unity Village, MO" → { locality, state_code }. Anything that // doesn't end in a real state code keeps the whole string as the // locality, and location_label carries the original either way. function splitPlace(label) { if (!label) return { locality: null, state_code: null }; const comma = label.lastIndexOf(","); if (comma === -1) return { locality: label.trim(), state_code: null }; const head = label.slice(0, comma).trim(); const tail = label.slice(comma + 1).trim(); return isStateCode(tail) ? { locality: head, state_code: tail } : { locality: label.trim(), state_code: null }; } function chapterLocation(chapter) { const online = chapter.state === null && !chapter.city?.includes(","); if (online || /^online$/i.test(chapter.city ?? "")) { return { locality: null, state_code: null, country: "US", location_label: chapter.city ?? "Online", is_online: 1, }; } if (chapter.state === "CANADA") { return { locality: splitPlace(chapter.city).locality, state_code: null, country: "CA", location_label: chapter.city, is_online: 0, }; } const { locality } = splitPlace(chapter.city); return { locality, state_code: isStateCode(chapter.state) ? chapter.state : null, country: "US", location_label: chapter.city, is_online: 0, }; } function eventLocation(label) { if (!label || /^online$/i.test(label)) { return { locality: null, state_code: null, country: "US", location_label: label ?? null, is_online: label ? 1 : 0, }; } const { locality, state_code } = splitPlace(label); return { locality, state_code, country: "US", location_label: label, is_online: 0 }; } /* ── Open ──────────────────────────────────────────────────── */ const db = await openDatabase(DB_PATH); migrate(db, { log: () => {} }); const version = db.prepare("PRAGMA user_version").get().user_version; if (version < 2) { throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`); } /* ── Statements ────────────────────────────────────────────── */ const ins = { org: db.prepare(` INSERT INTO organizations (id, kind, name, short_name, tagline, color, logo, venue, locality, state_code, country, location_label, is_online, is_published, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`), region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`), regionArea: db.prepare(` INSERT INTO region_areas (region_id, area_code, share, edge, note) VALUES (?, ?, ?, ?, ?)`), chapter: db.prepare(` INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`), section: db.prepare(` INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`), event: db.prepare(` INSERT INTO events (id, section_id, host_org_id, title, theme, date_label, status, locality, state_code, country, location_label, is_online, org_logo, event_logo, color, gradient, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), block: db.prepare(` INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text) VALUES (?, ?, ?, ?, ?, ?)`), link: db.prepare(` INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), }; const addParagraph = (kind, id, slot, order, text) => { if (!text) return; ins.block.run(kind, id, slot, order, "paragraph", text); }; /* ── Clear ───────────────────────────────────────────────────── Children before parents. Feedback is not in this list and is never cleared. ───────────────────────────────────────────────────────────── */ const CLEAR = [ "people_list_members", "people_lists", "person_awards", "awards", "event_people", "affiliations", "teams", "person_private", "people", "content_block_items", "content_blocks", "links", "events", "event_sections", "chapters", "region_areas", "regions", "organizations", ]; /* ── Import ────────────────────────────────────────────────── */ const counts = {}; const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n); tx(db, () => { for (const table of CLEAR) db.exec(`DELETE FROM ${table}`); db.exec("DELETE FROM sqlite_sequence"); /* ── The root organization ───────────────────────────────── */ ins.org.run( NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo, null, null, null, "US", null, 0, 0, ); bump("organizations"); /* ── Regions ─────────────────────────────────────────────── */ GROUPS.forEach((group, index) => { ins.org.run( group.id, "region", group.name, null, null, group.color, null, null, null, null, "US", null, 0, index, ); ins.region.run(group.id, group.scope, group.note ?? null); bump("organizations"); bump("regions"); // Whole areas. Split states are skipped here and handled below, // which matters for Iowa — it appears in great-lakes.states AND // in SPLITS, and inserting it twice would violate the key. for (const area of group.states) { if (SPLITS[area]) continue; ins.regionArea.run(group.id, area, 1.0, null, null); bump("region_areas"); } }); // Shared areas, one row per region. The old SPLITS gave the // sliver an explicit share and left the primary implicit; both // are explicit now, so the renderer never subtracts. for (const [area, split] of Object.entries(SPLITS)) { ins.regionArea.run( split.primary, area, Number((1 - split.share).toFixed(4)), opposite(split.edge), split.primaryNote ?? null, ); ins.regionArea.run( split.secondary, area, split.share, split.edge, split.secondaryNote ?? null, ); bump("region_areas", 2); } /* ── Chapters ────────────────────────────────────────────── */ CHAPTERS.forEach((chapter, index) => { const place = chapterLocation(chapter); const region = groupOf(chapter); if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`); ins.org.run( chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null, chapter.where ?? null, place.locality, place.state_code, place.country, place.location_label, place.is_online, index, ); ins.chapter.run( chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null, ); bump("organizations"); bump("chapters"); addParagraph("organization", chapter.id, "body", 0, chapter.about); let order = 0; if (chapter.link) { ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1); bump("links"); } if (chapter.contact) { ins.link.run( "organization", chapter.id, order++, "email", null, chapter.contact, `mailto:${chapter.contact}`, 0, ); bump("links"); } if (chapter.leads) { warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`); } }); /* ── Event sections ──────────────────────────────────────── Ids only. Titles, accents, colours, backgrounds and default views stay in Retreats.jsx. ─────────────────────────────────────────────────────────── */ eventsData.sections.forEach((section, index) => { ins.section.run(section.id, section.title, index); bump("event_sections"); }); /* ── Events ──────────────────────────────────────────────── */ const regionIds = new Set(GROUPS.map((g) => g.id)); // National retreats belong to NGU. Regional ones name their region // in the slug ("northwest-2026"). Partner placeholders have no // organization yet. function hostFor(event, sectionId) { if (sectionId === "national") return NGU.id; if (sectionId === "regional") { const match = [...regionIds] .filter((id) => event.id.startsWith(`${id}-`)) .sort((a, b) => b.length - a.length)[0]; if (match) return match; warn(`Event "${event.id}" is regional but names no region — host left null.`); return null; } warn(`Event "${event.id}" has no partner organization — host left null.`); return null; } for (const section of eventsData.sections) { section.events.forEach((event, index) => { const place = eventLocation(event.location); ins.event.run( event.id, section.id, hostFor(event, section.id), event.title, event.theme ?? null, event.date ?? null, event.status ?? null, place.locality, place.state_code, place.country, place.location_label, place.is_online, event.org_logo ?? null, event.image ?? null, event.color ?? null, event.gradient ?? null, index, ); bump("events"); // desc_a and desc_b become the card slot, in order. The body // slot is left empty for the full page you'll write later. addParagraph("event", event.id, "card", 0, event.desc_a); addParagraph("event", event.id, "card", 1, event.desc_b); let order = 0; (event.links ?? []).forEach((link, i) => { if (!/^https?:\/\//.test(link.link)) { warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`); } ins.link.run( "event", event.id, order++, "action", null, link.label, link.link, i === 0 ? 1 : 0, ); bump("links"); }); if (event.instagram) { ins.link.run( "event", event.id, order++, "social", "instagram", event.instagram, instagramUrl(event.instagram), 0, ); bump("links"); } }); } }); db.close(); /* ── Report ────────────────────────────────────────────────── */ console.log(`\nSeeded ${DB_PATH}\n`); for (const [table, n] of Object.entries(counts).sort()) { console.log(` ${String(n).padStart(4)} ${table}`); } if (warnings.length > 0) { console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`); for (const message of warnings) console.log(` · ${message}`); } console.log("");