NGU-Web/server/src/seed.js

398 lines
15 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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("");