v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

18
server/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "ngu-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"migrate": "node src/migrate-cli.js"
},
"dependencies": {
"@hono/node-server": "^1.0.0",
"hono": "^4.0.0"
}
}

120
server/src/db.js Normal file
View 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
View 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
View 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}`);

View 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'));

View 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
View 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();
};
}

View 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;

View 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
View 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
View 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 };