v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
398
server/src/seed.js
Normal file
398
server/src/seed.js
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
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("");
|
||||
Loading…
Add table
Add a link
Reference in a new issue