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
120
server/src/db.js
Normal file
120
server/src/db.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
DATABASE
|
||||
|
||||
One SQLite file, opened once at boot and held for the life of
|
||||
the process. Node's own sqlite module is used when it's there
|
||||
(Node 24+), better-sqlite3 otherwise. Their APIs overlap enough
|
||||
that everything below works against either, as long as you:
|
||||
|
||||
• use positional ? parameters, never named ones
|
||||
• pass 0/1 for booleans, never true/false
|
||||
• use tx() rather than db.transaction()
|
||||
|
||||
Those three rules are the whole compatibility story.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = join(HERE, "migrations");
|
||||
|
||||
/* ── Driver selection ──────────────────────────────────────── */
|
||||
|
||||
async function loadDriver() {
|
||||
try {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
return { Driver: DatabaseSync, name: "node:sqlite" };
|
||||
} catch {
|
||||
const { default: BetterSqlite3 } = await import("better-sqlite3");
|
||||
return { Driver: BetterSqlite3, name: "better-sqlite3" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Open ──────────────────────────────────────────────────────
|
||||
WAL readers never block the writer, which matters the
|
||||
moment a feedback POST lands mid page-load
|
||||
NORMAL fsync on checkpoint rather than every commit; safe
|
||||
under WAL, and much faster
|
||||
busy wait rather than throw if something else holds the
|
||||
write lock (a backup, usually)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export async function openDatabase(path) {
|
||||
const { Driver, name } = await loadDriver();
|
||||
const db = new Driver(path);
|
||||
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
db.exec("PRAGMA synchronous = NORMAL");
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
|
||||
db.driverName = name;
|
||||
return db;
|
||||
}
|
||||
|
||||
/* ── Transactions ──────────────────────────────────────────────
|
||||
node:sqlite has no db.transaction(), so do it by hand. Runs
|
||||
fn() and commits, or rolls back and rethrows.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function tx(db, fn) {
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
const result = fn();
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
db.exec("ROLLBACK");
|
||||
} catch {
|
||||
/* already rolled back */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Migrations ────────────────────────────────────────────────
|
||||
Files are NNN_name.sql. The leading number is the version.
|
||||
PRAGMA user_version tracks how far we've got, so there's no
|
||||
bookkeeping table and no ordering ambiguity.
|
||||
|
||||
Migrations only ever go forward. To undo something, write a
|
||||
new migration.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function migrate(db, { log = console.log } = {}) {
|
||||
const current = db.prepare("PRAGMA user_version").get().user_version;
|
||||
|
||||
const files = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
let applied = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const version = Number.parseInt(file.slice(0, 3), 10);
|
||||
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
|
||||
}
|
||||
if (version <= current) continue;
|
||||
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
|
||||
|
||||
tx(db, () => {
|
||||
db.exec(sql);
|
||||
// Not parameterisable, but version is a validated integer.
|
||||
db.exec(`PRAGMA user_version = ${version}`);
|
||||
});
|
||||
|
||||
log(`migrated → ${file}`);
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
const final = db.prepare("PRAGMA user_version").get().user_version;
|
||||
if (applied === 0) log(`schema up to date (v${final})`);
|
||||
|
||||
return final;
|
||||
}
|
||||
84
server/src/index.js
Normal file
84
server/src/index.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
NGU API
|
||||
|
||||
Binds to localhost only. nginx is the only thing that talks to
|
||||
it, which is what lets the routes trust X-Forwarded-For and skip
|
||||
CORS entirely — in production the API and the site share an
|
||||
origin, and in development Vite proxies /api so they share one
|
||||
there too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
|
||||
import { openDatabase, migrate } from "./db.js";
|
||||
import { rateLimit } from "./rateLimit.js";
|
||||
import content from "./routes/content.js";
|
||||
import feedback from "./routes/feedback.js";
|
||||
|
||||
const HOST = process.env.HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.PORT ?? 3001);
|
||||
const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────── */
|
||||
|
||||
const db = await openDatabase(DB_PATH);
|
||||
const version = migrate(db);
|
||||
|
||||
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
|
||||
|
||||
/* ── App ─────────────────────────────────────────────────────── */
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", logger());
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get("/api/health", (c) =>
|
||||
c.json({ ok: true, schema: version, driver: db.driverName }),
|
||||
);
|
||||
|
||||
app.route("/api", content);
|
||||
|
||||
// Tighter limit on the write path than anything else gets.
|
||||
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
||||
app.route("/api/feedback", feedback);
|
||||
|
||||
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error(err);
|
||||
// Never leak internals to the browser.
|
||||
return c.json({ error: "Something went wrong." }, 500);
|
||||
});
|
||||
|
||||
/* ── Serve ───────────────────────────────────────────────────── */
|
||||
|
||||
const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) =>
|
||||
console.log(`listening http://${info.address}:${info.port}`),
|
||||
);
|
||||
|
||||
/* ── Shutdown ──────────────────────────────────────────────────
|
||||
systemd sends SIGTERM on stop and restart. Closing the handle
|
||||
flushes the WAL cleanly, which saves a recovery pass on the
|
||||
next boot.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
for (const signal of ["SIGTERM", "SIGINT"]) {
|
||||
process.on(signal, () => {
|
||||
console.log(`${signal} — shutting down`);
|
||||
server.close(() => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
/* nothing useful to do here */
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
15
server/src/migrate-cli.js
Normal file
15
server/src/migrate-cli.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* Run migrations without starting the server.
|
||||
Useful in a deploy script, before restarting the unit.
|
||||
|
||||
DB_PATH=/var/lib/ngu/ngu.db pnpm migrate
|
||||
*/
|
||||
|
||||
import { openDatabase, migrate } from "./db.js";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
|
||||
|
||||
const db = await openDatabase(DB_PATH);
|
||||
const version = migrate(db);
|
||||
db.close();
|
||||
|
||||
console.log(`${DB_PATH} is at schema v${version}`);
|
||||
16
server/src/migrations/001_init.sql
Normal file
16
server/src/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 001_init.sql
|
||||
--
|
||||
-- Placeholder so the runner has something to do on first boot and
|
||||
-- you can confirm the plumbing works end to end. The real tables
|
||||
-- (regions, region_states, state_grid, chapters, events, feedback)
|
||||
-- land in 002.
|
||||
--
|
||||
-- Once 002 exists you can leave this file alone. Never edit a
|
||||
-- migration that has already run anywhere; write the next one.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now'));
|
||||
700
server/src/migrations/002_schema.sql
Normal file
700
server/src/migrations/002_schema.sql
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 002_schema.sql
|
||||
--
|
||||
-- Four things own a card and a page: organizations, events, people
|
||||
-- and teams. They share two tables — content_blocks for long-form
|
||||
-- description and links for buttons and socials — so a bio, an
|
||||
-- event description and a region's page all render through one
|
||||
-- component.
|
||||
--
|
||||
-- Tables are STRICT, so a column declared TEXT refuses an integer
|
||||
-- rather than quietly storing one. Worth it when the eventual
|
||||
-- writer is a web form.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- ORGANIZATIONS
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Regions, chapters, partners and NGU itself. They differ in a
|
||||
-- handful of fields, which live in side tables keyed by the same
|
||||
-- id, so events get one real foreign key to their host instead of
|
||||
-- a type/id pair SQLite can't check.
|
||||
--
|
||||
-- location_label is the display override for what the structured
|
||||
-- fields can't express: "Online", "Various venues", "Unity Village,
|
||||
-- MO". Read it first, fall back to composing from the parts.
|
||||
CREATE TABLE organizations (
|
||||
id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood'
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('national', 'region', 'chapter', 'partner')),
|
||||
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT,
|
||||
tagline TEXT, -- one line, for the card
|
||||
color TEXT,
|
||||
logo TEXT, -- filename in public/org-logos/
|
||||
|
||||
venue TEXT,
|
||||
address TEXT,
|
||||
locality TEXT,
|
||||
state_code TEXT, -- US only
|
||||
country TEXT NOT NULL DEFAULT 'US',
|
||||
location_label TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
|
||||
|
||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order);
|
||||
CREATE INDEX organizations_state_idx ON organizations (state_code);
|
||||
|
||||
|
||||
CREATE TABLE regions (
|
||||
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL
|
||||
CHECK (scope IN ('domestic', 'international', 'virtual')),
|
||||
map_note TEXT
|
||||
) STRICT;
|
||||
|
||||
|
||||
-- Which map areas a region covers, and how much of each.
|
||||
--
|
||||
-- area_code is a plain string matched at render time against the
|
||||
-- keys in mapGrid.js. No foreign key, because the thing it points
|
||||
-- at isn't in this database. An unrecognised code paints nothing,
|
||||
-- which is how Africa and the UK exist as regions with no tile.
|
||||
--
|
||||
-- Replaces both GROUPS.states and SPLITS. A region owning a whole
|
||||
-- area has share 1.0 and no edge. A shared area gets one row per
|
||||
-- region, each naming its own slice, so there's no primary and
|
||||
-- secondary to keep straight.
|
||||
CREATE TABLE region_areas (
|
||||
region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE,
|
||||
area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA'
|
||||
share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1),
|
||||
edge TEXT CHECK (edge IN ('top', 'bottom')),
|
||||
note TEXT, -- 'north', 'Salt Lake City area'
|
||||
PRIMARY KEY (region_id, area_code)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX region_areas_area_idx ON region_areas (area_code);
|
||||
|
||||
|
||||
-- region_id is stored rather than derived from the state. Deriving
|
||||
-- it is what forced the per-chapter override in split states; the
|
||||
-- admin form should default it from the state and only ask when the
|
||||
-- state has more than one row in region_areas.
|
||||
--
|
||||
-- No `leads` column. Who runs a chapter is an affiliation, exactly
|
||||
-- as it is for every other organization.
|
||||
CREATE TABLE chapters (
|
||||
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
region_id TEXT REFERENCES regions (id) ON DELETE SET NULL,
|
||||
meets TEXT, -- '2nd Sundays, 6:00pm'
|
||||
started TEXT -- 'Since 2021'
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX chapters_region_idx ON chapters (region_id);
|
||||
|
||||
|
||||
-- Partners get no side table. Everything they need is already on
|
||||
-- organizations, and a table holding nothing but a primary key is
|
||||
-- a place for confusion rather than data.
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- EVENTS
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Sections are defined in Retreats.jsx, which owns their titles,
|
||||
-- accents, default colours and backgrounds. This table exists only
|
||||
-- so section_id can be a real foreign key: an unrecognised value
|
||||
-- would make an event vanish from the page with no error anywhere,
|
||||
-- which is a bug someone hunts for an hour.
|
||||
--
|
||||
-- `name` is an internal label for the eventual admin dropdown. The
|
||||
-- site never renders it.
|
||||
CREATE TABLE event_sections (
|
||||
id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner'
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
|
||||
|
||||
-- Dates are stored three ways on purpose:
|
||||
--
|
||||
-- starts_on / ends_on ISO dates, nullable. What sorting and the
|
||||
-- upcoming/past split run on.
|
||||
-- date_label what the card shows. Real data includes
|
||||
-- "March/April 2026", which no date type
|
||||
-- holds and no formatter should reproduce.
|
||||
-- status an override. Null derives from ends_on,
|
||||
-- so there's no flag to remember to flip.
|
||||
CREATE TABLE events (
|
||||
id TEXT PRIMARY KEY,
|
||||
section_id TEXT NOT NULL REFERENCES event_sections (id),
|
||||
host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
theme TEXT,
|
||||
tagline TEXT,
|
||||
|
||||
starts_on TEXT, -- 'YYYY-MM-DD'
|
||||
ends_on TEXT,
|
||||
date_label TEXT,
|
||||
status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')),
|
||||
|
||||
venue TEXT,
|
||||
address TEXT,
|
||||
locality TEXT,
|
||||
state_code TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'US',
|
||||
location_label TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
|
||||
|
||||
org_logo TEXT, -- null → host's logo
|
||||
event_logo TEXT,
|
||||
color TEXT, -- null → host's, then the page's
|
||||
gradient TEXT,
|
||||
|
||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order);
|
||||
CREATE INDEX events_host_idx ON events (host_org_id);
|
||||
CREATE INDEX events_date_idx ON events (starts_on);
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- PEOPLE
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Public by design. Everything in this table can appear on a card,
|
||||
-- and is_published = 0 is the only thing between a row and the
|
||||
-- open web — hence the default of 0, unlike organizations.
|
||||
-- Anything that must never be served lives in person_private, so a
|
||||
-- careless SELECT * can't leak it.
|
||||
--
|
||||
-- Bio goes in content_blocks: 'card' slot for the two lines under
|
||||
-- a photo, 'body' slot for the full page with headings and lists.
|
||||
-- Socials and personal sites go in links.
|
||||
CREATE TABLE people (
|
||||
id TEXT PRIMARY KEY, -- slug: 'jane-doe'
|
||||
display_name TEXT NOT NULL, -- 'Jane Doe'
|
||||
sort_name TEXT, -- 'Doe, Jane' — list ordering
|
||||
pronouns TEXT, -- 'she/her'
|
||||
tagline TEXT, -- fallback when no title applies
|
||||
photo TEXT, -- filename in public/people/
|
||||
|
||||
public_email TEXT, -- safe to print on the site
|
||||
public_phone TEXT,
|
||||
|
||||
locality TEXT,
|
||||
state_code TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'US',
|
||||
location_label TEXT,
|
||||
|
||||
is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name);
|
||||
|
||||
|
||||
-- Never joined into a public response. A separate table rather than
|
||||
-- extra columns so the boundary is structural instead of a rule
|
||||
-- someone has to remember.
|
||||
--
|
||||
-- birth_date rather than age: an age column is wrong within a year
|
||||
-- of being written. Derive it when needed, and consider first
|
||||
-- whether you need it at all — Planning Center already holds
|
||||
-- registration data, and the least sensitive record is the one you
|
||||
-- never made.
|
||||
CREATE TABLE person_private (
|
||||
person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE,
|
||||
birth_date TEXT, -- 'YYYY-MM-DD'
|
||||
private_email TEXT,
|
||||
private_phone TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
) STRICT;
|
||||
|
||||
|
||||
-- ── Teams ──────────────────────────────────────────────────────
|
||||
--
|
||||
-- A team belongs to exactly one organization: NGU national has a
|
||||
-- Board and a Leadership Team, a region or chapter can have its
|
||||
-- own. An organization with a flat structure needs none — its
|
||||
-- affiliations simply carry no team_id.
|
||||
--
|
||||
-- UNIQUE (id, org_id) looks redundant against the primary key, and
|
||||
-- it is — except that it gives affiliations a composite foreign key
|
||||
-- to point at, which is what stops someone filing a person under a
|
||||
-- team belonging to a different organization.
|
||||
CREATE TABLE teams (
|
||||
id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
|
||||
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
tagline TEXT,
|
||||
color TEXT,
|
||||
logo TEXT,
|
||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (id, org_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
|
||||
|
||||
|
||||
-- ── Affiliations ───────────────────────────────────────────────
|
||||
--
|
||||
-- The leadership list for every organization on the site. A chapter
|
||||
-- lead, a regional coordinator and a national board member are the
|
||||
-- same kind of row; only org_id differs.
|
||||
--
|
||||
-- One person can hold several: chapter lead in Lynnwood and board
|
||||
-- member nationally are two rows.
|
||||
--
|
||||
-- ended_on null means current. Keeping past roles rather than
|
||||
-- deleting them is what makes an alumni list possible later.
|
||||
--
|
||||
-- is_owner marks authority within the organization, and is
|
||||
-- deliberately orthogonal to role — a board member and a chapter
|
||||
-- lead can both be owners, a long-serving volunteer isn't. It
|
||||
-- drives billing order on cards. It is NOT an edit permission:
|
||||
-- when the admin pages arrive, who may change an organization's
|
||||
-- content belongs in its own table, because the person who
|
||||
-- maintains a page is often not the person who runs the chapter.
|
||||
--
|
||||
-- Deleting a team that still has members fails rather than
|
||||
-- silently detaching them. That's the composite foreign key doing
|
||||
-- its job; clear or reassign the members first.
|
||||
CREATE TABLE affiliations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
team_id TEXT,
|
||||
|
||||
title TEXT, -- 'Board Chair', 'Chapter Lead'
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')),
|
||||
is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)),
|
||||
started_on TEXT,
|
||||
ended_on TEXT, -- null = current
|
||||
|
||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX affiliations_person_idx ON affiliations (person_id);
|
||||
CREATE INDEX affiliations_org_idx
|
||||
ON affiliations (org_id, is_public, is_owner DESC, sort_order);
|
||||
CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order);
|
||||
|
||||
|
||||
-- ── People at events ───────────────────────────────────────────
|
||||
--
|
||||
-- Both the public billing (speakers, leaders) and the private
|
||||
-- record of who attended, distinguished by is_public rather than by
|
||||
-- table. It defaults to 0, so a new row is invisible until someone
|
||||
-- decides otherwise — the right way round for this.
|
||||
--
|
||||
-- If attendance ever becomes real check-in data synced from
|
||||
-- Planning Center, that belongs in its own table. This one is for
|
||||
-- the handful of names worth remembering per event.
|
||||
CREATE TABLE event_people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||
|
||||
role TEXT NOT NULL DEFAULT 'attendee'
|
||||
CHECK (role IN ('speaker', 'leader', 'facilitator', 'host',
|
||||
'musician', 'volunteer', 'attendee')),
|
||||
title TEXT, -- 'Keynote Speaker'
|
||||
is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE (event_id, person_id, role)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order);
|
||||
CREATE INDEX event_people_person_idx ON event_people (person_id);
|
||||
|
||||
|
||||
-- ── Awards ─────────────────────────────────────────────────────
|
||||
--
|
||||
-- An award exists independently of who won it, which is why it's
|
||||
-- two tables and not a text column on people.
|
||||
CREATE TABLE awards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
logo TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE person_awards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||
award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE,
|
||||
event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented
|
||||
awarded_on TEXT,
|
||||
citation TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
|
||||
UNIQUE (person_id, award_id, awarded_on)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX person_awards_person_idx ON person_awards (person_id);
|
||||
|
||||
|
||||
-- ── Curated lists ──────────────────────────────────────────────
|
||||
--
|
||||
-- Teams and affiliations are structural: they describe how an
|
||||
-- organization is actually run. Lists are editorial: "2026 Retreat
|
||||
-- Speakers", "Founders", anything a page wants to show that isn't
|
||||
-- an org chart. If it turns out affiliations cover everything, this
|
||||
-- pair is easy to drop — nothing depends on it.
|
||||
CREATE TABLE people_lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
blurb TEXT,
|
||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE people_list_members (
|
||||
list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||
note TEXT, -- overrides tagline in this list
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (list_id, person_id)
|
||||
) STRICT;
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- CONTENT BLOCKS
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Long-form description as ordered rows, shared by all four card
|
||||
-- types.
|
||||
--
|
||||
-- slot 'card' is the short version on the tile — an event's
|
||||
-- desc_a and desc_b become two paragraph blocks here.
|
||||
-- 'body' is the full page. Same renderer, different query.
|
||||
--
|
||||
-- Blocks with children (list, links) use content_block_items.
|
||||
--
|
||||
-- owner_kind + owner_id is polymorphic, which SQLite can't express
|
||||
-- as a foreign key. The triggers below do the work a FK would.
|
||||
CREATE TABLE content_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_kind TEXT NOT NULL
|
||||
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
|
||||
owner_id TEXT NOT NULL,
|
||||
slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
type TEXT NOT NULL
|
||||
CHECK (type IN ('heading', 'subheading', 'paragraph',
|
||||
'list', 'links', 'quote', 'image', 'divider')),
|
||||
text TEXT,
|
||||
media TEXT,
|
||||
href TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX content_blocks_owner_idx
|
||||
ON content_blocks (owner_kind, owner_id, slot, sort_order);
|
||||
|
||||
|
||||
CREATE TABLE content_block_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
text TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
url TEXT -- null → plain list item
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX content_block_items_block_idx
|
||||
ON content_block_items (block_id, sort_order);
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- LINKS
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Entity-level links: a Register button, an Instagram handle, a
|
||||
-- personal site. Distinct from links inside a content block, which
|
||||
-- are part of a sentence rather than a control.
|
||||
CREATE TABLE links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_kind TEXT NOT NULL
|
||||
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
|
||||
owner_id TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
kind TEXT NOT NULL DEFAULT 'action'
|
||||
CHECK (kind IN ('action', 'social', 'website', 'email')),
|
||||
platform TEXT, -- 'instagram', 'discord'
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order);
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- FEEDBACK
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- The only table the public can write to.
|
||||
--
|
||||
-- page_path and section_id are free text rather than foreign keys
|
||||
-- on purpose: they record where someone was when they wrote, and
|
||||
-- that shouldn't change meaning when a route is later renamed.
|
||||
CREATE TABLE feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
feedback_type TEXT NOT NULL DEFAULT 'general',
|
||||
message TEXT NOT NULL,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
|
||||
page_path TEXT,
|
||||
section_id TEXT,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'new'
|
||||
CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')),
|
||||
admin_note TEXT,
|
||||
user_agent TEXT,
|
||||
ip_hash TEXT -- hashed, never the address
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC);
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- INTEGRITY FOR THE POLYMORPHIC TABLES
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TRIGGER content_blocks_owner_exists
|
||||
BEFORE INSERT ON content_blocks
|
||||
BEGIN
|
||||
SELECT CASE
|
||||
WHEN new.owner_kind = 'event'
|
||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'content_blocks: no such event')
|
||||
WHEN new.owner_kind = 'organization'
|
||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'content_blocks: no such organization')
|
||||
WHEN new.owner_kind = 'person'
|
||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'content_blocks: no such person')
|
||||
WHEN new.owner_kind = 'team'
|
||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'content_blocks: no such team')
|
||||
END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER links_owner_exists
|
||||
BEFORE INSERT ON links
|
||||
BEGIN
|
||||
SELECT CASE
|
||||
WHEN new.owner_kind = 'event'
|
||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'links: no such event')
|
||||
WHEN new.owner_kind = 'organization'
|
||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'links: no such organization')
|
||||
WHEN new.owner_kind = 'person'
|
||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'links: no such person')
|
||||
WHEN new.owner_kind = 'team'
|
||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
|
||||
THEN RAISE(ABORT, 'links: no such team')
|
||||
END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER organizations_cleanup
|
||||
AFTER DELETE ON organizations
|
||||
BEGIN
|
||||
DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id;
|
||||
DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER events_cleanup
|
||||
AFTER DELETE ON events
|
||||
BEGIN
|
||||
DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id;
|
||||
DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER people_cleanup
|
||||
AFTER DELETE ON people
|
||||
BEGIN
|
||||
DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id;
|
||||
DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER teams_cleanup
|
||||
AFTER DELETE ON teams
|
||||
BEGIN
|
||||
DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
|
||||
DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
|
||||
END;
|
||||
|
||||
|
||||
-- ── updated_at ─────────────────────────────────────────────────
|
||||
-- The WHEN guard stops the trigger recursing, and lets an explicit
|
||||
-- updated_at through untouched, which matters when importing.
|
||||
|
||||
CREATE TRIGGER organizations_touch
|
||||
AFTER UPDATE ON organizations
|
||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||
BEGIN
|
||||
UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER events_touch
|
||||
AFTER UPDATE ON events
|
||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||
BEGIN
|
||||
UPDATE events SET updated_at = datetime('now') WHERE id = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER people_touch
|
||||
AFTER UPDATE ON people
|
||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||
BEGIN
|
||||
UPDATE people SET updated_at = datetime('now') WHERE id = new.id;
|
||||
END;
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- VIEWS
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Events with the host resolved and the logo/colour fallbacks
|
||||
-- applied, so no handler has to remember the rules. An event with
|
||||
-- no colour of its own inherits its host organization's; if that's
|
||||
-- null too, the page applies the section default, which is where
|
||||
-- that default lives.
|
||||
CREATE VIEW v_events AS
|
||||
SELECT
|
||||
e.*,
|
||||
o.name AS host_name,
|
||||
o.kind AS host_kind,
|
||||
o.logo AS host_logo,
|
||||
COALESCE(e.org_logo, o.logo) AS effective_org_logo,
|
||||
COALESCE(e.color, o.color) AS effective_color,
|
||||
COALESCE(
|
||||
e.status,
|
||||
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
||||
THEN 'past' ELSE 'upcoming' END
|
||||
) AS effective_status
|
||||
FROM events e
|
||||
LEFT JOIN organizations o ON o.id = e.host_org_id;
|
||||
|
||||
|
||||
-- Chapters flattened for the list. The API adds a map area to each
|
||||
-- row using mapGrid.js; that can't happen here because the grid
|
||||
-- isn't in this database. Leadership comes from v_org_leadership,
|
||||
-- filtered on the chapter's id.
|
||||
CREATE VIEW v_chapters AS
|
||||
SELECT
|
||||
o.id, o.name, o.short_name, o.tagline, o.color, o.logo,
|
||||
o.venue, o.locality, o.state_code, o.country, o.location_label,
|
||||
o.is_online, o.sort_order,
|
||||
c.region_id, c.meets, c.started,
|
||||
r.name AS region_name,
|
||||
r.color AS region_color
|
||||
FROM organizations o
|
||||
JOIN chapters c ON c.id = o.id
|
||||
LEFT JOIN organizations r ON r.id = c.region_id
|
||||
WHERE o.is_published = 1;
|
||||
|
||||
|
||||
-- Current, public leadership of any organization. Owners first,
|
||||
-- then explicit order, then name. A chapter page, a region page and
|
||||
-- the national Leadership page all read this; the only difference
|
||||
-- is the org_id they filter on, and whether they group by team.
|
||||
CREATE VIEW v_org_leadership AS
|
||||
SELECT
|
||||
a.org_id,
|
||||
a.team_id,
|
||||
t.name AS team_name,
|
||||
t.sort_order AS team_sort_order,
|
||||
a.person_id,
|
||||
a.title,
|
||||
a.role,
|
||||
a.is_owner,
|
||||
a.sort_order,
|
||||
p.display_name,
|
||||
p.sort_name,
|
||||
p.pronouns,
|
||||
p.tagline,
|
||||
p.photo,
|
||||
p.public_email
|
||||
FROM affiliations a
|
||||
JOIN people p ON p.id = a.person_id AND p.is_published = 1
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
WHERE a.is_public = 1
|
||||
AND a.ended_on IS NULL
|
||||
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
|
||||
|
||||
|
||||
-- Every public affiliation a person holds, current or past. Feeds
|
||||
-- the "affiliated organizations" block on a person's page, where
|
||||
-- past roles are worth showing and v_org_leadership's current-only
|
||||
-- filter would hide them.
|
||||
CREATE VIEW v_person_affiliations AS
|
||||
SELECT
|
||||
a.person_id,
|
||||
a.org_id,
|
||||
a.team_id,
|
||||
a.title,
|
||||
a.role,
|
||||
a.is_owner,
|
||||
a.started_on,
|
||||
a.ended_on,
|
||||
(a.ended_on IS NULL) AS is_current,
|
||||
a.sort_order,
|
||||
o.name AS org_name,
|
||||
o.kind AS org_kind,
|
||||
o.logo AS org_logo,
|
||||
o.color AS org_color,
|
||||
t.name AS team_name
|
||||
FROM affiliations a
|
||||
JOIN organizations o ON o.id = a.org_id
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
WHERE a.is_public = 1;
|
||||
|
||||
|
||||
-- Public event billing only. Attendance rows stay out, because
|
||||
-- is_public defaults to 0.
|
||||
CREATE VIEW v_event_people AS
|
||||
SELECT
|
||||
ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order,
|
||||
p.display_name, p.pronouns, p.tagline, p.photo
|
||||
FROM event_people ep
|
||||
JOIN people p ON p.id = ep.person_id AND p.is_published = 1
|
||||
WHERE ep.is_public = 1;
|
||||
49
server/src/rateLimit.js
Normal file
49
server/src/rateLimit.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
RATE LIMIT
|
||||
|
||||
A fixed window counter held in process memory. It resets when
|
||||
the service restarts and it doesn't survive a second instance,
|
||||
both of which are fine for one systemd unit on one box.
|
||||
|
||||
The client IP comes from X-Forwarded-For, which nginx sets. That
|
||||
header is only trustworthy because nothing but nginx can reach
|
||||
this port — it binds to 127.0.0.1. Don't expose the port.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export function rateLimit({ windowMs = 60_000, max = 10 } = {}) {
|
||||
const hits = new Map(); // ip → { count, resetAt }
|
||||
|
||||
// Drop expired entries occasionally so the map can't grow forever.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of hits) {
|
||||
if (entry.resetAt <= now) hits.delete(ip);
|
||||
}
|
||||
}, windowMs).unref();
|
||||
|
||||
return async (c, next) => {
|
||||
const ip =
|
||||
c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
|
||||
|
||||
const now = Date.now();
|
||||
let entry = hits.get(ip);
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
entry = { count: 0, resetAt: now + windowMs };
|
||||
hits.set(ip, entry);
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
return c.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
429,
|
||||
{ "Retry-After": String(retryAfter) },
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
397
server/src/routes/content.js
Normal file
397
server/src/routes/content.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
CONTENT ROUTES — read-only, mounted under /api
|
||||
|
||||
GET /events list + the section ids
|
||||
GET /events/:id one event, full body, people
|
||||
GET /organizations list, ?kind=region|chapter|…
|
||||
GET /organizations/:id one organization's page
|
||||
|
||||
Organizations are one table, so they're one endpoint. A region
|
||||
and a chapter differ by a handful of fields, which arrive under
|
||||
`details` rather than as separate routes — that's what lets a
|
||||
list component be written once and pointed at any kind.
|
||||
|
||||
Responses carry their fallbacks already resolved: an event's
|
||||
`color` is its own or its host's, and `status` is derived from
|
||||
the dates when it isn't set. Components read one field and don't
|
||||
reimplement the rules.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
|
||||
|
||||
const content = new Hono();
|
||||
|
||||
// Content changes weekly at most, and a stale minute costs nobody
|
||||
// anything. stale-while-revalidate keeps the page instant while the
|
||||
// refresh happens behind it.
|
||||
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||
|
||||
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||
|
||||
const ORG_KINDS = ["national", "region", "chapter", "partner"];
|
||||
|
||||
const marks = (n) => Array(n).fill("?").join(",");
|
||||
|
||||
/* ── Shapers ───────────────────────────────────────────────── */
|
||||
|
||||
function shapeEvent(row, links, cardBlocks) {
|
||||
const { actions, instagram } = splitLinks(links);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
section_id: row.section_id,
|
||||
|
||||
title: row.title,
|
||||
theme: row.theme,
|
||||
tagline: row.tagline,
|
||||
|
||||
starts_on: row.starts_on,
|
||||
ends_on: row.ends_on,
|
||||
date_label: row.date_label,
|
||||
status: row.effective_status,
|
||||
|
||||
location_label: row.location_label,
|
||||
locality: row.locality,
|
||||
state_code: row.state_code,
|
||||
country: row.country,
|
||||
is_online: asBool(row.is_online),
|
||||
|
||||
org_logo: row.effective_org_logo,
|
||||
event_logo: row.event_logo,
|
||||
color: row.effective_color,
|
||||
gradient: row.gradient,
|
||||
|
||||
host: row.host_org_id
|
||||
? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
|
||||
: null,
|
||||
|
||||
description: paragraphs(cardBlocks),
|
||||
links: actions,
|
||||
instagram,
|
||||
};
|
||||
}
|
||||
|
||||
/* The common card surface every organization has, whatever kind it
|
||||
is. Kind-specific fields go in `details`, attached by the caller. */
|
||||
function shapeOrganization(row, links, cardBlocks, bodyBlocks) {
|
||||
const { actions, socials, website, email, instagram } = splitLinks(links);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
short_name: row.short_name,
|
||||
tagline: row.tagline,
|
||||
color: row.color,
|
||||
logo: row.logo,
|
||||
|
||||
venue: row.venue,
|
||||
address: row.address,
|
||||
locality: row.locality,
|
||||
state_code: row.state_code,
|
||||
country: row.country,
|
||||
location_label: row.location_label,
|
||||
is_online: asBool(row.is_online),
|
||||
sort_order: row.sort_order,
|
||||
|
||||
description: paragraphs(cardBlocks),
|
||||
blocks: bodyBlocks,
|
||||
links: actions,
|
||||
socials,
|
||||
website,
|
||||
email,
|
||||
instagram,
|
||||
|
||||
details: {},
|
||||
leadership: [],
|
||||
};
|
||||
}
|
||||
|
||||
function shapeLeader(row) {
|
||||
return {
|
||||
person_id: row.person_id,
|
||||
display_name: row.display_name,
|
||||
pronouns: row.pronouns,
|
||||
title: row.title ?? row.tagline,
|
||||
role: row.role,
|
||||
is_owner: asBool(row.is_owner),
|
||||
photo: row.photo,
|
||||
public_email: row.public_email,
|
||||
team_id: row.team_id,
|
||||
team_name: row.team_name,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Kind-specific details, batched ────────────────────────────
|
||||
Each of these runs a fixed number of queries for the whole list
|
||||
rather than one per organization.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
function attachRegionDetails(db, orgs) {
|
||||
const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`)
|
||||
.all(...ids);
|
||||
|
||||
const areas = db
|
||||
.prepare(
|
||||
`SELECT region_id, area_code, share, edge, note
|
||||
FROM region_areas
|
||||
WHERE region_id IN (${marks(ids.length)})
|
||||
ORDER BY area_code, share DESC`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
// A region's chapters, enough of each for a list entry.
|
||||
const children = db
|
||||
.prepare(
|
||||
`SELECT c.region_id, o.id, o.name, o.location_label, o.logo
|
||||
FROM chapters c
|
||||
JOIN organizations o ON o.id = c.id AND o.is_published = 1
|
||||
WHERE c.region_id IN (${marks(ids.length)})
|
||||
ORDER BY o.sort_order, o.name`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
const areasBy = new Map();
|
||||
const childrenBy = new Map();
|
||||
|
||||
for (const row of areas) {
|
||||
const list = areasBy.get(row.region_id);
|
||||
const entry = {
|
||||
area_code: row.area_code,
|
||||
share: row.share,
|
||||
edge: row.edge,
|
||||
note: row.note,
|
||||
};
|
||||
if (list) list.push(entry);
|
||||
else areasBy.set(row.region_id, [entry]);
|
||||
}
|
||||
|
||||
for (const row of children) {
|
||||
const list = childrenBy.get(row.region_id);
|
||||
const entry = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
location_label: row.location_label,
|
||||
logo: row.logo,
|
||||
};
|
||||
if (list) list.push(entry);
|
||||
else childrenBy.set(row.region_id, [entry]);
|
||||
}
|
||||
|
||||
for (const org of orgs) {
|
||||
if (org.kind !== "region") continue;
|
||||
org.details = {
|
||||
scope: byId[org.id]?.scope ?? null,
|
||||
map_note: byId[org.id]?.map_note ?? null,
|
||||
areas: areasBy.get(org.id) ?? [],
|
||||
chapters: childrenBy.get(org.id) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function attachChapterDetails(db, orgs) {
|
||||
const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.region_id, c.meets, c.started,
|
||||
r.name AS region_name, r.color AS region_color
|
||||
FROM chapters c
|
||||
LEFT JOIN organizations r ON r.id = c.region_id
|
||||
WHERE c.id IN (${marks(ids.length)})`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
|
||||
for (const org of orgs) {
|
||||
if (org.kind !== "chapter") continue;
|
||||
const row = byId[org.id] ?? {};
|
||||
org.details = {
|
||||
region_id: row.region_id ?? null,
|
||||
region_name: row.region_name ?? null,
|
||||
region_color: row.region_color ?? null,
|
||||
meets: row.meets ?? null,
|
||||
started: row.started ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function attachLeadership(db, orgs) {
|
||||
const ids = orgs.map((o) => o.id);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`)
|
||||
.all(...ids);
|
||||
|
||||
const byOrg = new Map();
|
||||
for (const row of rows) {
|
||||
const list = byOrg.get(row.org_id);
|
||||
if (list) list.push(shapeLeader(row));
|
||||
else byOrg.set(row.org_id, [shapeLeader(row)]);
|
||||
}
|
||||
|
||||
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
|
||||
}
|
||||
|
||||
/* ── Events ────────────────────────────────────────────────────
|
||||
Flat, with the section ids alongside. Retreats.tsx owns the
|
||||
section titles and colours and filters this list by section_id.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
content.get("/events", (c) => {
|
||||
const db = c.get("db");
|
||||
|
||||
const sections = db
|
||||
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
|
||||
.all();
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM v_events
|
||||
WHERE is_published = 1
|
||||
ORDER BY section_id, sort_order`,
|
||||
)
|
||||
.all();
|
||||
|
||||
const ids = rows.map((row) => row.id);
|
||||
const links = loadLinks(db, "event", ids);
|
||||
const cards = loadBlocks(db, "event", ids, "card");
|
||||
|
||||
const events = rows.map((row) =>
|
||||
shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
|
||||
);
|
||||
|
||||
return json(c, { sections, events });
|
||||
});
|
||||
|
||||
|
||||
/* One event, for its own page. */
|
||||
content.get("/events/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = c.req.param("id");
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`)
|
||||
.get(id);
|
||||
|
||||
if (!row) return c.json({ error: "No such event" }, 404);
|
||||
|
||||
const links = loadLinks(db, "event", [id]).get(id) ?? [];
|
||||
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
|
||||
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
|
||||
|
||||
const people = db
|
||||
.prepare(
|
||||
`SELECT person_id, display_name, pronouns, tagline, photo, role, title
|
||||
FROM v_event_people WHERE event_id = ? ORDER BY sort_order`,
|
||||
)
|
||||
.all(id);
|
||||
|
||||
return json(c, {
|
||||
event: { ...shapeEvent(row, links, cards), blocks: body, people },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/* ── Organizations ─────────────────────────────────────────────
|
||||
GET /organizations every published org
|
||||
GET /organizations?kind=region one kind
|
||||
GET /organizations?kind=region,chapter
|
||||
|
||||
Whatever the kind, the common card fields are in the same
|
||||
places, so a list component reads `name`, `color`, `logo` and
|
||||
`description` without knowing what it's holding, and reaches
|
||||
into `details` only when it wants kind-specific extras.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
content.get("/organizations", (c) => {
|
||||
const db = c.get("db");
|
||||
|
||||
const kindParam = c.req.query("kind");
|
||||
const kinds = kindParam
|
||||
? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k))
|
||||
: [];
|
||||
|
||||
if (kindParam && kinds.length === 0) {
|
||||
return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400);
|
||||
}
|
||||
|
||||
const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : "";
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM organizations
|
||||
WHERE is_published = 1 ${filter}
|
||||
ORDER BY kind, sort_order, name`,
|
||||
)
|
||||
.all(...kinds);
|
||||
|
||||
const ids = rows.map((row) => row.id);
|
||||
const links = loadLinks(db, "organization", ids);
|
||||
const cards = loadBlocks(db, "organization", ids, "card");
|
||||
const bodies = loadBlocks(db, "organization", ids, "body");
|
||||
|
||||
const organizations = rows.map((row) =>
|
||||
shapeOrganization(
|
||||
row,
|
||||
links.get(row.id) ?? [],
|
||||
cards.get(row.id) ?? [],
|
||||
bodies.get(row.id) ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
attachRegionDetails(db, organizations);
|
||||
attachChapterDetails(db, organizations);
|
||||
attachLeadership(db, organizations);
|
||||
|
||||
return json(c, { organizations });
|
||||
});
|
||||
|
||||
|
||||
/* One organization's page: region, chapter, partner or NGU. */
|
||||
content.get("/organizations/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = c.req.param("id");
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`)
|
||||
.get(id);
|
||||
|
||||
if (!row) return c.json({ error: "No such organization" }, 404);
|
||||
|
||||
const links = loadLinks(db, "organization", [id]).get(id) ?? [];
|
||||
const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? [];
|
||||
const body = loadBlocks(db, "organization", [id], "body").get(id) ?? [];
|
||||
|
||||
const organization = shapeOrganization(row, links, cards, body);
|
||||
const one = [organization];
|
||||
|
||||
attachRegionDetails(db, one);
|
||||
attachChapterDetails(db, one);
|
||||
attachLeadership(db, one);
|
||||
|
||||
// Everything this organization is hosting or has hosted.
|
||||
organization.events = db
|
||||
.prepare(
|
||||
`SELECT id, title, date_label, effective_status AS status,
|
||||
location_label, event_logo, effective_color AS color
|
||||
FROM v_events
|
||||
WHERE host_org_id = ? AND is_published = 1
|
||||
ORDER BY sort_order`,
|
||||
)
|
||||
.all(id);
|
||||
|
||||
return json(c, { organization });
|
||||
});
|
||||
|
||||
export default content;
|
||||
130
server/src/routes/feedback.js
Normal file
130
server/src/routes/feedback.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FEEDBACK ROUTE — the only public write on the site
|
||||
|
||||
Everything hostile that will ever reach this service arrives
|
||||
here, so the defences live here rather than being sprinkled
|
||||
around:
|
||||
|
||||
rate limit applied where this router is mounted (5/min)
|
||||
honeypot a field real users never see or fill
|
||||
length caps rejected before anything touches the database
|
||||
no HTML stored verbatim, escaped at render time
|
||||
|
||||
The write is a single INSERT with positional parameters, so it
|
||||
works on node:sqlite and better-sqlite3 alike. No transaction:
|
||||
one statement is already atomic.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const feedback = new Hono();
|
||||
|
||||
const LIMITS = {
|
||||
name: 120,
|
||||
email: 254,
|
||||
message: 5000,
|
||||
pagePath: 200,
|
||||
sectionId: 80,
|
||||
};
|
||||
|
||||
const MIN_MESSAGE = 10;
|
||||
|
||||
// Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx.
|
||||
// Anything else falls back to 'general' rather than being rejected —
|
||||
// a renamed option shouldn't lose someone's submission.
|
||||
const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"];
|
||||
|
||||
/* ── IP hashing ────────────────────────────────────────────────
|
||||
Stored so repeat abuse from one source is visible during
|
||||
triage, hashed so the table never holds an address. Without
|
||||
IP_SALT the salt is regenerated each boot, which makes hashes
|
||||
incomparable across restarts — fine for dev, set it in
|
||||
/etc/ngu/api.env for production.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
|
||||
|
||||
if (!process.env.IP_SALT) {
|
||||
console.warn("IP_SALT unset — feedback ip_hash values reset on restart");
|
||||
}
|
||||
|
||||
function hashIp(ip) {
|
||||
if (!ip) return null;
|
||||
return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
/* ── Input cleaning ──────────────────────────────────────────── */
|
||||
|
||||
function clean(value, max) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().slice(0, max);
|
||||
}
|
||||
|
||||
// Deliberately permissive. Rejecting odd-but-valid addresses loses
|
||||
// real submissions, and the field is optional anyway.
|
||||
function looksLikeEmail(value) {
|
||||
return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
/* ── POST /api/feedback ──────────────────────────────────────── */
|
||||
|
||||
feedback.post("/", async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Expected a JSON body." }, 400);
|
||||
}
|
||||
|
||||
// Honeypot. A bot fills every input it finds; a person can't see
|
||||
// this one. Return success so the bot doesn't learn anything.
|
||||
if (clean(body.website, 50) !== "") {
|
||||
return c.body(null, 204);
|
||||
}
|
||||
|
||||
const name = clean(body.name, LIMITS.name);
|
||||
const email = clean(body.email, LIMITS.email);
|
||||
const message = clean(body.message, LIMITS.message);
|
||||
const pagePath = clean(body.pagePath, LIMITS.pagePath);
|
||||
// The picker holds nav hashes ('#chapters'); the column holds ids.
|
||||
const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, "");
|
||||
const feedbackType = TYPES.includes(body.feedbackType)
|
||||
? body.feedbackType
|
||||
: "general";
|
||||
|
||||
const errors = {};
|
||||
if (message.length < MIN_MESSAGE) errors.message = "Please write a little more.";
|
||||
if (!looksLikeEmail(email)) errors.email = "That email doesn't look right.";
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return c.json({ error: "Validation failed", fields: errors }, 422);
|
||||
}
|
||||
|
||||
const db = c.get("db");
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO feedback
|
||||
(feedback_type, message, name, email, page_path, section_id,
|
||||
user_agent, ip_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
feedbackType,
|
||||
message,
|
||||
name || null,
|
||||
email || null,
|
||||
pagePath || null,
|
||||
sectionId || null,
|
||||
clean(c.req.header("user-agent"), 500) || null,
|
||||
hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()),
|
||||
);
|
||||
|
||||
// created_at and status come from column defaults.
|
||||
console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`);
|
||||
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
export default feedback;
|
||||
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("");
|
||||
147
server/src/shape.js
Normal file
147
server/src/shape.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
SHAPE
|
||||
|
||||
Blocks and links are polymorphic: any organization, event, person
|
||||
or team can own them. Every list endpoint therefore needs the
|
||||
same move — fetch the parent rows, then fetch all their children
|
||||
in one query each and stitch.
|
||||
|
||||
The alternative is a query per row, which at a dozen events is
|
||||
invisible and at two hundred is not. Three queries is three
|
||||
queries whatever the row count, so it may as well be right now.
|
||||
|
||||
One limit worth knowing: SQLite caps bound parameters per
|
||||
statement (999 on older builds). If a list ever exceeds that,
|
||||
these need chunking. Nothing here comes close.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const placeholders = (n) => Array(n).fill("?").join(",");
|
||||
|
||||
const asBool = (value) => value === 1;
|
||||
|
||||
/* ── Links ─────────────────────────────────────────────────────
|
||||
Map of owner_id → links, in sort order.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function loadLinks(db, ownerKind, ids) {
|
||||
const out = new Map();
|
||||
if (ids.length === 0) return out;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT owner_id, kind, platform, label, url, is_primary
|
||||
FROM links
|
||||
WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)})
|
||||
ORDER BY owner_id, sort_order`,
|
||||
)
|
||||
.all(ownerKind, ...ids);
|
||||
|
||||
for (const row of rows) {
|
||||
const link = {
|
||||
kind: row.kind,
|
||||
platform: row.platform,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
is_primary: asBool(row.is_primary),
|
||||
};
|
||||
const list = out.get(row.owner_id);
|
||||
if (list) list.push(link);
|
||||
else out.set(row.owner_id, [link]);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── Blocks ────────────────────────────────────────────────────
|
||||
Map of owner_id → blocks for one slot, items attached.
|
||||
|
||||
Two queries: the blocks, then every item belonging to them.
|
||||
Blocks with no children come back with an empty items array
|
||||
rather than no key, so the renderer never has to check.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function loadBlocks(db, ownerKind, ids, slot = "body") {
|
||||
const out = new Map();
|
||||
if (ids.length === 0) return out;
|
||||
|
||||
const blockRows = db
|
||||
.prepare(
|
||||
`SELECT id, owner_id, type, text, media, href
|
||||
FROM content_blocks
|
||||
WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)})
|
||||
ORDER BY owner_id, sort_order`,
|
||||
)
|
||||
.all(ownerKind, slot, ...ids);
|
||||
|
||||
if (blockRows.length === 0) return out;
|
||||
|
||||
const byId = new Map();
|
||||
|
||||
for (const row of blockRows) {
|
||||
const block = {
|
||||
type: row.type,
|
||||
text: row.text,
|
||||
media: row.media,
|
||||
href: row.href,
|
||||
items: [],
|
||||
};
|
||||
byId.set(row.id, block);
|
||||
|
||||
const list = out.get(row.owner_id);
|
||||
if (list) list.push(block);
|
||||
else out.set(row.owner_id, [block]);
|
||||
}
|
||||
|
||||
const blockIds = [...byId.keys()];
|
||||
|
||||
const itemRows = db
|
||||
.prepare(
|
||||
`SELECT block_id, text, detail, url
|
||||
FROM content_block_items
|
||||
WHERE block_id IN (${placeholders(blockIds.length)})
|
||||
ORDER BY block_id, sort_order`,
|
||||
)
|
||||
.all(...blockIds);
|
||||
|
||||
for (const row of itemRows) {
|
||||
byId.get(row.block_id)?.items.push({
|
||||
text: row.text,
|
||||
detail: row.detail,
|
||||
url: row.url,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── Card description ──────────────────────────────────────────
|
||||
The card slot is paragraphs by convention, so it collapses to
|
||||
an array of strings — desc_a and desc_b become description[0]
|
||||
and description[1]. A non-paragraph block in the card slot is
|
||||
ignored here; put it in the body slot instead.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function paragraphs(blocks = []) {
|
||||
return blocks
|
||||
.filter((block) => block.type === "paragraph" && block.text)
|
||||
.map((block) => block.text);
|
||||
}
|
||||
|
||||
/* ── Split entity links ────────────────────────────────────────
|
||||
Socials are lifted out of the list because cards treat them
|
||||
differently: Instagram is an icon, Register is a button. The
|
||||
underlying rows are the same table.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function splitLinks(links = []) {
|
||||
return {
|
||||
actions: links.filter((link) => link.kind === "action"),
|
||||
socials: links.filter((link) => link.kind === "social"),
|
||||
website: links.find((link) => link.kind === "website")?.url ?? null,
|
||||
email: links.find((link) => link.kind === "email")?.label ?? null,
|
||||
instagram:
|
||||
links.find((link) => link.platform === "instagram")?.label ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export { asBool };
|
||||
Loading…
Add table
Add a link
Reference in a new issue