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

141
server/src/routes/people.js Normal file
View file

@ -0,0 +1,141 @@
/* ═══════════════════════════════════════════════════════════════
PEOPLE ROUTES — read-only, mounted under /api
GET /teams/:id/people current public members of a team
GET /people?ids=a,b,c named people, any order
The team route reads v_org_leadership, which already decides who
counts as current and public — affiliation still open, marked
public, person published. Restating those conditions here is how
they drift apart.
That view is affiliation-driven, so it can't serve the lookup
route: a person with no affiliations would vanish, and one with
several would appear more than once. The lookup reads people
directly and returns no title, because a bare slug names no seat.
Photos are filenames, as everywhere else in this API. The
component decides where they live.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { asBool } from "../shape.js";
const people = new Hono();
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const marks = (n) => Array(n).fill("?").join(",");
/* How many slugs one request may name. Keeps the URL sane. */
const MAX_IDS = 50;
function shapePerson(row) {
return {
id: row.id ?? row.person_id,
name: row.display_name,
// Only the team route knows a seat; the lookup route doesn't.
title: row.title ?? null,
tagline: row.tagline,
pronouns: row.pronouns,
photo: row.photo,
location_label: row.location_label,
public_email: row.public_email,
org: row.primary_org_id
? { id: row.primary_org_id, name: row.primary_org_name }
: null,
bio: splitParagraphs(row.bio),
role: row.role ?? null,
is_owner: row.is_owner === undefined ? false : asBool(row.is_owner),
};
}
/* people.bio is one run of prose with blank lines between
paragraphs — unrelated to shape.js's paragraphs(), which turns
content_block rows into text. */
function splitParagraphs(text) {
if (!text) return null;
const parts = text
.split(/\n\s*\n/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length ? parts : null;
}
/* ── One team's people ─────────────────────────────────────── */
people.get("/teams/:id/people", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const team = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE id = ? AND is_published = 1`,
)
.get(id);
if (!team) return c.json({ error: "No such team" }, 404);
// The view orders by org first, which isn't what a single team
// wants, so the order is restated here.
const rows = db
.prepare(
`SELECT * FROM v_org_leadership
WHERE team_id = ?
ORDER BY is_owner DESC,
sort_order,
COALESCE(sort_name, display_name),
display_name`,
)
.all(id);
return json(c, { team, people: rows.map(shapePerson) });
});
/* ── People by id ──────────────────────────────────────────────
For hand-picked lists in a section. Order is the caller's, so
there's no ORDER BY here.
───────────────────────────────────────────────────────────── */
people.get("/people", (c) => {
const db = c.get("db");
const ids = [
...new Set(
(c.req.query("ids") ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean),
),
].slice(0, MAX_IDS);
if (ids.length === 0) return json(c, { people: [] });
const rows = db
.prepare(
`SELECT p.id,
p.display_name,
p.pronouns,
p.photo,
p.tagline,
p.location_label,
p.public_email,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM people p
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE p.id IN (${marks(ids.length)})
AND p.is_published = 1`,
)
.all(...ids);
return json(c, { people: rows.map(shapePerson) });
});
export default people;