v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
518
server/src/admin-schema.js
Normal file
518
server/src/admin-schema.js
Normal 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`,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue