v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

518
server/src/admin-schema.js Normal file
View file

@ -0,0 +1,518 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY DESCRIPTORS
One object per editable entity. Everything the CRUD handlers do
— validation, SQL, nesting — is read from here, so adding a
table later is a descriptor rather than another set of
hand-written statements to keep in step with the schema.
Anatomy of a descriptor:
columns writable columns of the parent row
extensions 1:1 side tables, optionally gated on a column
value (organizations.kind decides whether a
regions or chapters row should exist)
children ordered collections, replaced wholesale on save
Replacing children wholesale is only safe because nothing has a
foreign key INTO these tables. That is the dividing line, and
it is why teams and awards are entities of their own rather
than repeaters on the organization form: affiliations.team_id
and person_awards.award_id point at them, so a delete-and-
reinsert save would abort the moment either had a single
dependent row.
Parent ids are immutable. Polymorphic children reference their
owner by free-text owner_id, so renaming a slug in place would
silently orphan every link and content block attached to it.
affiliations is edited from both ends — a person's roles, and a
team's members. Each side deletes and reinserts only its own
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
every column of the row, so a save from one side round-trips
what the other side owns rather than blanking it.
═══════════════════════════════════════════════════════════════ */
/* ── Column helpers ──────────────────────────────────────────── */
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
const enumeration = (name, values, opts = {}) => ({
name,
type: "enum",
values,
...opts,
});
/* Place columns shared by organizations and events, in schema order. */
const placeColumns = [
text("venue"),
text("address"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
real("latitude"),
real("longitude"),
bool("is_online"),
];
/* The affiliation's own fields, minus whichever end owns the row.
Both editors write the same shape so neither loses the other's
values on save. */
const affiliationRole = [
text("title"),
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
required: true,
}),
bool("is_owner"),
date("started_on"),
date("ended_on"),
bool("is_public"),
];
/* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({
key: "links",
table: "links",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("kind", ["action", "social", "website", "email"], {
required: true,
}),
text("platform"),
text("label", { required: true }),
text("url", { required: true }),
bool("is_primary"),
],
});
const blocksChild = (ownerKind) => ({
key: "content_blocks",
table: "content_blocks",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("slot", ["card", "body"], { required: true }),
enumeration(
"type",
[
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
{ required: true },
),
text("text"),
text("media"),
text("href"),
],
children: [
{
key: "items",
table: "content_block_items",
owner: { column: "block_id" },
order: "sort_order",
columns: [
text("text", { required: true }),
text("detail"),
text("url"),
],
},
],
});
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
key: "organizations",
table: "organizations",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"name",
"short_name",
"locality",
"state_code",
"is_published",
"sort_order",
"updated_at",
],
filters: ["kind", "is_published"],
search: ["name", "id", "locality"],
order: "kind, sort_order, name",
},
columns: [
enumeration("kind", ["national", "region", "chapter", "partner"], {
required: true,
}),
text("name", { required: true }),
text("short_name"),
text("tagline"),
text("color"),
text("logo"),
...placeColumns,
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "region",
table: "regions",
idColumn: "id",
when: { column: "kind", value: "region" },
columns: [
enumeration("scope", ["domestic", "international", "virtual"], {
required: true,
}),
text("map_note"),
],
},
{
key: "chapter",
table: "chapters",
idColumn: "id",
when: { column: "kind", value: "chapter" },
columns: [text("region_id"), text("meets"), text("started")],
},
],
children: [
linksChild("organization"),
blocksChild("organization"),
{
key: "region_areas",
table: "region_areas",
owner: { column: "region_id" },
when: { column: "kind", value: "region" },
order: "area_code",
columns: [
text("area_code", { required: true }),
real("share"),
enumeration("edge", ["top", "bottom"]),
text("note"),
],
},
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
table: "events",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"title",
"section_id",
"host_org_id",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
filters: ["section_id", "status", "is_published", "host_org_id"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("host_org_id"),
text("title", { required: true }),
text("theme"),
text("tagline"),
date("starts_on"),
date("ends_on"),
text("date_label"),
enumeration("status", ["upcoming", "past", "cancelled"]),
...placeColumns,
text("org_logo"),
text("event_logo"),
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
],
children: [
linksChild("event"),
blocksChild("event"),
{
key: "event_people",
table: "event_people",
owner: { column: "event_id" },
order: "sort_order",
columns: [
text("person_id", { required: true }),
enumeration(
"role",
[
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
{ required: true },
),
text("title"),
bool("is_public"),
],
},
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
table: "people",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"display_name",
"sort_name",
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
},
columns: [
text("display_name", { required: true }),
text("sort_name"),
text("pronouns"),
text("tagline"),
text("photo"),
text("bio"),
text("primary_org_id"),
text("public_email"),
text("public_phone"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "private",
table: "person_private",
idColumn: "person_id",
touch: true, // has its own updated_at with no trigger behind it
columns: [
date("birth_date"),
text("private_email"),
text("private_phone"),
text("address"),
text("notes"),
],
},
],
children: [
linksChild("person"),
blocksChild("person"),
{
key: "affiliations",
table: "affiliations",
owner: { column: "person_id" },
// sort_order is where this person sits on that team, so it
// belongs to the team's editor. reindex: false stops this
// form renumbering by row position; declaring the column
// keeps the team's value intact across a save here.
reindex: false,
order: "org_id, team_id, sort_order",
columns: [
text("org_id", { required: true }),
text("team_id"),
...affiliationRole,
int("sort_order"),
],
},
{
key: "person_awards",
table: "person_awards",
owner: { column: "person_id" },
order: "awarded_on",
columns: [
text("award_id", { required: true }),
text("event_id"),
date("awarded_on"),
text("citation"),
bool("is_public"),
],
},
],
};
/* ── Teams ───────────────────────────────────────────────────── */
// A team belongs to exactly one organization, and affiliations
// point at the pair (team_id, org_id) rather than the team alone.
// Two consequences the form has to live with:
//
// · org_id cannot be changed once anyone is filed under the
// team. The composite foreign key has no ON UPDATE CASCADE, so
// SQLite aborts the UPDATE. That surfaces as a constraint
// error, which is the correct answer — reassign the members
// first.
//
// · 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",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id", { required: true }),
text("name", { required: true }),
text("tagline"),
text("color"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
children: [
// 'team' is already a valid owner_kind in both polymorphic
// tables, and teams_cleanup drops the rows on delete, so a team
// page comes free.
linksChild("team"),
blocksChild("team"),
{
key: "members",
table: "affiliations",
// org_id is inherited from the team rather than asked for:
// the composite foreign key (team_id, org_id) means a member
// of this team can only belong to this team's organization,
// so a second dropdown could only ever be wrong.
owner: { column: "team_id", inherit: { org_id: "org_id" } },
// Row position is the order they appear on the public site.
// This is the only editor that writes it.
order: "sort_order",
columns: [text("person_id", { required: true }), ...affiliationRole],
},
],
};
/* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because
// an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding
// org being deleted.
//
// No children: 'award' is not in the owner_kind CHECK on
// content_blocks or links. If awards ever need a page of their
// own, that CHECK is a table rebuild, so decide before adding one
// rather than after.
const awards = {
key: "awards",
table: "awards",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id"),
text("name", { required: true }),
text("description"),
text("logo"),
int("sort_order"),
],
};
export const ENTITIES = { organizations, events, people, teams, awards };
/* ── Options for the form's select inputs ────────────────────── */
export const OPTION_QUERIES = {
organizations:
"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",
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",
// org_id rides along so the affiliation row can filter the list
// down to teams of the organization it already names.
teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// The awarding organization is folded into the label instead,
// because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate
// when two orgs name an award the same thing.
awards: `
SELECT a.id,
CASE WHEN o.name IS NULL THEN a.name
ELSE a.name || ' — ' || o.name END AS label,
a.org_id
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id
ORDER BY a.sort_order, a.name`,
};