diff --git a/CLAUDE.md b/CLAUDE.md
index 6c67a54..946a0b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,7 +71,7 @@ Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `ad
## Migrations
- Sequential files: `001_`, `002_`, ...
- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block.
-- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved.
+- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. A rebuild whose table is named by views or triggers wraps the rename in `PRAGMA legacy_alter_table = ON ... OFF` (see 023).
## Integrations
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.
\ No newline at end of file
diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js
index e457bb1..b4e01a2 100644
--- a/server/src/admin-schema.js
+++ b/server/src/admin-schema.js
@@ -299,28 +299,27 @@ const events = {
columns: [
"id",
"title",
- "section_id",
+ "scope_id",
"event_type",
"date_label",
"starts_on",
"status",
"is_published",
- "sort_order",
"updated_at",
],
// No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
- filters: ["section_id", "event_type", "status", "is_published"],
+ filters: ["scope_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
- order: "sort_order, starts_on DESC, title",
+ order: "starts_on IS NULL, starts_on DESC, title",
},
columns: [
- text("section_id", { required: true }),
+ text("scope_id", { required: true }),
- // What kind of gathering, as against section_id's which band of
- // the page. Declared required even though the column has a
+ // What kind of gathering, as against scope_id's whose gathering
+ // it is. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
@@ -344,7 +343,6 @@ const events = {
text("color"),
text("gradient"),
bool("is_published"),
- int("sort_order"),
bool("in_timeline"),
// A repeating schedule. Columns rather than a side table: the
@@ -410,12 +408,11 @@ const people = {
"tagline",
"locality",
"is_published",
- "sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
- order: "sort_order, sort_name, display_name",
+ order: "sort_name, display_name",
},
columns: [
@@ -433,7 +430,6 @@ const people = {
text("country"),
text("location_label"),
bool("is_published"),
- int("sort_order"),
],
extensions: [
@@ -505,18 +501,15 @@ const people = {
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
-//
-// No concurrency column: teams have no updated_at. Adding one
-// means rebuilding a STRICT table for a row that one person edits
-// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
+ concurrency: "updated_at",
list: {
- columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
+ columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
@@ -575,8 +568,8 @@ const awards = {
idKind: "slug",
list: {
- columns: ["id", "org_id", "name", "description", "sort_order"],
- filters: ["org_id"],
+ columns: ["id", "org_id", "name", "description", "is_published", "sort_order"],
+ filters: ["org_id", "is_published"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
@@ -586,6 +579,7 @@ const awards = {
text("name", { required: true }),
text("description"),
text("logo"),
+ bool("is_published"),
int("sort_order"),
],
};
@@ -787,7 +781,7 @@ export const OPTION_QUERIES = {
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
- event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
+ event_scopes: "SELECT id, name AS label FROM event_scopes ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
diff --git a/server/src/db.js b/server/src/db.js
index ad669b6..53f5349 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -82,6 +82,16 @@ export function tx(db, fn) {
Migrations only ever go forward. To undo something, write a
new migration.
+
+ Foreign keys are off while migrations run, the recipe from
+ the SQLite docs for changing a table's shape. PRAGMA
+ foreign_keys is a no-op inside a transaction, so it has to be
+ set here, around the per-file transactions, rather than in
+ the file. With it on, rebuilding a table something references
+ (drop the old one, rename the new one into place) either
+ cascades into the children or fails the commit. Instead each
+ file ends with a foreign_key_check, and any orphan it leaves
+ rolls that file back.
───────────────────────────────────────────────────────────── */
export function migrate(db, { log = console.log } = {}) {
@@ -92,25 +102,41 @@ export function migrate(db, { log = console.log } = {}) {
.sort();
let applied = 0;
+ const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
+ db.exec("PRAGMA foreign_keys = OFF");
- for (const file of files) {
- const version = Number.parseInt(file.slice(0, 3), 10);
+ try {
+ 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 (!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);
+
+ const orphans = db.prepare("PRAGMA foreign_key_check").all();
+ if (orphans.length > 0) {
+ const where = orphans
+ .slice(0, 5)
+ .map((o) => `${o.table} row ${o.rowid} → ${o.parent}`)
+ .join(", ");
+ throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`);
+ }
+
+ // Not parameterisable, but version is a validated integer.
+ db.exec(`PRAGMA user_version = ${version}`);
+ });
+
+ log(`migrated → ${file}`);
+ applied += 1;
}
- 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;
+ } finally {
+ if (enforced) db.exec("PRAGMA foreign_keys = ON");
}
const final = db.prepare("PRAGMA user_version").get().user_version;
diff --git a/server/src/migrations/020_drop_unused.sql b/server/src/migrations/020_drop_unused.sql
new file mode 100644
index 0000000..a389ae2
--- /dev/null
+++ b/server/src/migrations/020_drop_unused.sql
@@ -0,0 +1,23 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 020 DROP WHAT NOTHING READS
+--
+-- people_lists and people_list_members were planned for
+-- hand-picked rosters and never got a route, a descriptor or a
+-- component. v_chapters and v_person_affiliations have no query
+-- against them: the routes read organizations and affiliations
+-- directly.
+--
+-- Views first, then the child table before its parent. No trigger
+-- references either table, and nothing has a foreign key into
+-- them, so no rebuild and no PRAGMA foreign_keys dance.
+--
+-- PRAGMA user_version; -- reads 19 before this file
+-- ═══════════════════════════════════════════════════════════════
+
+DROP VIEW IF EXISTS v_chapters;
+DROP VIEW IF EXISTS v_person_affiliations;
+
+DROP TABLE IF EXISTS people_list_members;
+DROP TABLE IF EXISTS people_lists;
+
+PRAGMA user_version = 20; -- ← set to this migration's number
diff --git a/server/src/migrations/021_event_scopes.sql b/server/src/migrations/021_event_scopes.sql
new file mode 100644
index 0000000..a5ae68a
--- /dev/null
+++ b/server/src/migrations/021_event_scopes.sql
@@ -0,0 +1,32 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 021 event_sections → event_scopes
+--
+-- 015 established that the table is a scope list — whose gathering
+-- an event is — and kept the old name to avoid a rename across the
+-- codebase. This is that rename: the table, and events.section_id
+-- to scope_id. The API field and the useEvents filter follow in
+-- the same change.
+--
+-- Both renames run with legacy_alter_table off (the default), so
+-- SQLite carries them into the REFERENCES clause on events, the
+-- index on section_id, and v_events, which selects e.*.
+--
+-- It also inserts the three scopes 015 only relabelled. The
+-- retired seed script was what created national, regional and
+-- partner, so a database built from migrations alone had no row
+-- for the Retreats page's three bands to point at. INSERT OR
+-- IGNORE with 015's names and order: a no-op where they exist.
+--
+-- PRAGMA user_version; -- reads 20 before this file
+-- ═══════════════════════════════════════════════════════════════
+
+ALTER TABLE event_sections RENAME TO event_scopes;
+
+ALTER TABLE events RENAME COLUMN section_id TO scope_id;
+
+INSERT OR IGNORE INTO event_scopes (id, name, sort_order) VALUES
+ ('national', 'National', 10),
+ ('regional', 'Regional', 20),
+ ('partner', 'Partner', 50);
+
+PRAGMA user_version = 21; -- ← set to this migration's number
diff --git a/server/src/migrations/022_drop_sort_orders.sql b/server/src/migrations/022_drop_sort_orders.sql
new file mode 100644
index 0000000..dde1332
--- /dev/null
+++ b/server/src/migrations/022_drop_sort_orders.sql
@@ -0,0 +1,36 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 022 DROP events.sort_order AND people.sort_order
+--
+-- events.sort_order dates from the seed, when event dates were
+-- free text ("March/April 2026") and a typed number was the only
+-- way to order a list. Events have real dates now, and a new one
+-- landed at 0 — ahead of everything — until someone renumbered.
+-- The routes order by date instead.
+--
+-- people.sort_order was never read by the public site: people
+-- sort by sort_name everywhere. It only reordered the admin list.
+--
+-- Every other sort_order stays. On child collections it is the
+-- row's position, written by the admin engine from drag order;
+-- on organizations, teams, awards, timeline_entries and
+-- event_scopes it is an ordering the site actually uses.
+--
+-- SQLite refuses DROP COLUMN while an index names the column, so
+-- the three indexes that do go first and come back without it.
+-- No view names either column (v_events selects e.*).
+--
+-- PRAGMA user_version; -- reads 21 before this file
+-- ═══════════════════════════════════════════════════════════════
+
+DROP INDEX IF EXISTS events_section_idx;
+DROP INDEX IF EXISTS events_type_idx;
+DROP INDEX IF EXISTS people_sort_idx;
+
+ALTER TABLE events DROP COLUMN sort_order;
+ALTER TABLE people DROP COLUMN sort_order;
+
+CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on);
+CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on);
+CREATE INDEX people_sort_idx ON people (is_published, sort_name);
+
+PRAGMA user_version = 22; -- ← set to this migration's number
diff --git a/server/src/migrations/023_teams_updated_at.sql b/server/src/migrations/023_teams_updated_at.sql
new file mode 100644
index 0000000..7a41651
--- /dev/null
+++ b/server/src/migrations/023_teams_updated_at.sql
@@ -0,0 +1,87 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 023 teams GETS created_at AND updated_at
+--
+-- Every other top-level entity has updated_at, which the admin
+-- engine compares on save so two people editing the same row
+-- can't silently overwrite each other. teams was left out because
+-- ADD COLUMN can't take DEFAULT (datetime('now')) on a STRICT
+-- table, so this is the rebuild — the recipe from the SQLite docs.
+--
+-- The runner turns foreign keys off around every migration and
+-- runs foreign_key_check before committing (see migrate() in
+-- db.js). So DROP TABLE doesn't touch affiliations, whose
+-- (team_id, org_id) reference keeps pointing at the name `teams`,
+-- which the rename puts back underneath it.
+--
+-- legacy_alter_table is on for the rename: v_org_leadership,
+-- v_timeline and the timeline_entries ref triggers name `teams`.
+-- Between the DROP and the RENAME that table doesn't exist, and
+-- the modern rename re-parses every view and trigger and fails on
+-- them. Legacy mode leaves them alone; they resolve to the new
+-- table by name. Turned back off before the end, since the
+-- connection outlives this file.
+--
+-- DROP TABLE takes the two AFTER DELETE triggers with it (and does
+-- not fire them), so they're recreated below exactly as 002 and
+-- 007 wrote them, along with teams_touch. Triggers go last: the
+-- runner may drop statements that follow a BEGIN...END body.
+--
+-- Verify after:
+--
+-- PRAGMA user_version; -- 23
+-- PRAGMA foreign_key_check; -- no rows
+-- SELECT name FROM sqlite_master WHERE tbl_name = 'teams';
+-- ═══════════════════════════════════════════════════════════════
+
+PRAGMA legacy_alter_table = ON;
+
+CREATE TABLE teams_new (
+ 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,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE (id, org_id)
+) STRICT;
+
+-- Columns listed explicitly rather than SELECT *, so this breaks
+-- loudly if the old shape isn't what this file assumes. Existing
+-- rows get the migration time for both timestamps.
+INSERT INTO teams_new
+ (id, org_id, name, tagline, color, logo, is_published, sort_order)
+SELECT
+ id, org_id, name, tagline, color, logo, is_published, sort_order
+ FROM teams;
+
+DROP TABLE teams;
+
+ALTER TABLE teams_new RENAME TO teams;
+
+CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
+
+PRAGMA legacy_alter_table = OFF;
+
+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;
+
+CREATE TRIGGER timeline_teams_cleanup
+AFTER DELETE ON teams
+BEGIN
+ DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
+END;
+
+CREATE TRIGGER teams_touch
+AFTER UPDATE ON teams
+FOR EACH ROW WHEN new.updated_at = old.updated_at
+BEGIN
+ UPDATE teams SET updated_at = datetime('now') WHERE id = new.id;
+END;
diff --git a/server/src/routes/content.js b/server/src/routes/content.js
index 2138905..92b74c8 100644
--- a/server/src/routes/content.js
+++ b/server/src/routes/content.js
@@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api
- GET /events list + the section ids
+ GET /events list + the event scopes
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page
@@ -20,8 +20,8 @@
from the dates when it isn't set. Components read one field and
don't reimplement the rules.
- `event_type` is orthogonal to `section_id`: the section is which
- band of the Retreats page an event belongs to, the type is what
+ `event_type` is orthogonal to `scope_id`: the scope is whose
+ gathering it is (national, regional, partner…), the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
@@ -120,7 +120,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
return {
id: row.id,
- section_id: row.section_id,
+ scope_id: row.scope_id,
event_type: row.event_type,
title: row.title,
@@ -399,9 +399,7 @@ function attachTeams(db, orgs) {
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
-/* The awards this organization gives. awards has no is_published
- column, so every row is public the moment it exists — see the
- note in the route below. */
+/* The awards this organization gives, drafts left out. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
@@ -414,7 +412,7 @@ function attachAwards(db, orgs) {
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
- WHERE a.org_id IN (${marks(ids.length)})
+ WHERE a.org_id IN (${marks(ids.length)}) AND a.is_published = 1
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
@@ -437,22 +435,33 @@ function attachAwards(db, orgs) {
}
/* ── Events ────────────────────────────────────────────────────
- Flat, with the section ids alongside. Retreats.tsx owns the
- section titles and colours and filters this list by section_id.
+ Flat, with the event scopes alongside. Retreats.tsx owns the
+ band titles and colours and filters this list by scope_id.
+
+ By date, the way the cards split them: upcoming soonest first,
+ then past most recent first, undated at the end of each. There
+ is no hand-typed order; a tie falls to the title.
───────────────────────────────────────────────────────────── */
+const EVENT_ORDER = `
+ effective_status = 'past',
+ starts_on IS NULL,
+ CASE WHEN effective_status = 'past' THEN NULL ELSE starts_on END,
+ starts_on DESC,
+ title`;
+
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`)
+ const scopes = db
+ .prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`)
.all();
const rows = db
.prepare(
`SELECT * FROM v_events
WHERE is_published = 1
- ORDER BY section_id, sort_order`,
+ ORDER BY ${EVENT_ORDER}`,
)
.all();
@@ -470,7 +479,7 @@ content.get("/events", (c) => {
),
);
- return json(c, { sections, events });
+ return json(c, { scopes, events });
});
@@ -507,7 +516,7 @@ content.get("/events/:id", (c) => {
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
- JOIN awards a ON a.id = pa.award_id
+ JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
@@ -622,7 +631,7 @@ content.get("/organizations/:id", (c) => {
FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
- ORDER BY e.sort_order`,
+ ORDER BY ${EVENT_ORDER}`,
)
.all(id);
@@ -694,12 +703,8 @@ content.get("/teams/:id", (c) => {
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
- `awards` has no is_published column: an award is public the
- moment somebody creates it, and there is no way to draft one.
- That was fine while awards only appeared as a line on a
- person's record; it is thinner ground now that each has a URL.
- A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
- worth doing before this ships.
+ An unpublished award is a draft: left out of the list, and a
+ 404 at its own URL, the same as any other unpublished row.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
@@ -718,7 +723,7 @@ content.get("/awards", (c) => {
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
- ${org ? "WHERE a.org_id = ?" : ""}
+ WHERE a.is_published = 1 ${org ? "AND a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
@@ -741,7 +746,7 @@ content.get("/awards/:id", (c) => {
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
- WHERE a.id = ?`,
+ WHERE a.id = ? AND a.is_published = 1`,
)
.get(id);
diff --git a/server/src/routes/home.js b/server/src/routes/home.js
index bbf14c4..fed4a29 100644
--- a/server/src/routes/home.js
+++ b/server/src/routes/home.js
@@ -151,7 +151,7 @@ home.get("/front-page", (c) => {
.prepare(
`SELECT * FROM v_events
WHERE ${notOver}
- ORDER BY starts_on, sort_order
+ ORDER BY starts_on, title
LIMIT 1`,
)
.get();
diff --git a/server/src/routes/people.js b/server/src/routes/people.js
index dbc31d9..8d094e9 100644
--- a/server/src/routes/people.js
+++ b/server/src/routes/people.js
@@ -225,7 +225,7 @@ people.get("/people/:id", (c) => {
WHERE person_id = ?
) x
JOIN v_events e ON e.id = x.event_id AND e.is_published = 1
- ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`,
+ ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`,
)
.all(id, id);
diff --git a/src/App.tsx.save b/src/App.tsx.save
deleted file mode 100644
index 3e0287c..0000000
--- a/src/App.tsx.save
+++ /dev/null
@@ -1,292 +0,0 @@
-import { useState } from "react";
-import nguLogo from "@/NGU_Logo.svg";
-import fallLogo from "@/Fall Logo.svg";
-import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg";
-
-{/* SVGs */}
-const DoveSVG = ({ className = "" }: { className?: string }) => (
-
-);
-
-const InstagramIcon = () => (
-
-);
-
-const FacebookIcon = () => (
-
-);
-
-const DiscordIcon = () => (
-
-);
-
-{/* Link Tables */}
-const Social_Links = [
- { label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", icon:
- A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service. -
-- We are the future of the Unity Movement. -
- - -- NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living. -
-{card.desc}
-"Consciousness Creates"
-November 12-15th, 2026
-Unity Village, MO
-Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus your highest self.
-Registration starting at $150, and $75 loding cost.
- -· More events coming soon, stay connected for announcements ·
-Find your place in the NGU community
-{item.label}
-{item.desc}
-