diff --git a/src/components/admin/ActivityTab.astro b/src/components/admin/ActivityTab.astro deleted file mode 100644 index d4e7fa3..0000000 --- a/src/components/admin/ActivityTab.astro +++ /dev/null @@ -1,44 +0,0 @@ ---- -import type { ActivityRow } from '../../lib/db'; -import { fmtDateTime } from '../../lib/markdown'; - -interface Props { - rows: ActivityRow[]; -} - -const { rows } = Astro.props; ---- -
-
-

Recent activity

-

- The raw activity feed — what powers the ticker on /pulse. Read-only debug view. - Showing up to 200 most-recent events; the ticker takes the last 12 within 7 days. -

- - {rows.length === 0 ? ( -

No activity recorded yet.

- ) : ( - - - - - - - - - - - {rows.map(r => ( - - - - - - - ))} - -
WhenActorKindSubject
{fmtDateTime(r.created_at)}{r.actor_name} ({r.actor_role}){r.kind}{r.subject_type} #{r.subject_id}
- )} -
-
diff --git a/src/components/admin/DispatchesTab.astro b/src/components/admin/DispatchesTab.astro deleted file mode 100644 index bd620a7..0000000 --- a/src/components/admin/DispatchesTab.astro +++ /dev/null @@ -1,268 +0,0 @@ ---- -import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db'; -import { fmtDateTime } from '../../lib/markdown'; -import { dispatchKindLabel } from '../../lib/format'; - -interface Props { - dispatches: DispatchWithAuthor[]; - editing: DispatchWithAuthor | null; - editingPoll: PulseRow | null; - fenjaUsers: UserPublic[]; - currentUserId: number; -} - -const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props; - -function toInputValue(sql: string | null | undefined): string { - if (!sql) return ''; - return sql.replace(' ', 'T').slice(0, 16); -} - -const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : []; -while (pollOptionsForForm.length < 4) pollOptionsForForm.push(''); - -const STATUS_LABEL: Record = { - draft: 'Draft', - published: 'Published', - archived: 'Archived', -}; - -const formAction = editing ? 'update_dispatch' : 'create_dispatch'; -const defaultAuthorId = editing?.author_id ?? currentUserId; ---- -
- -
-

{editing ? 'Edit dispatch' : 'New dispatch'}

- -
- - {editing && } - -
- - -
- -
-
- - -
-
- - -
-
- -
- - - Write 2–4 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty. -
- -
- - -
- - {!editing && ( -
-
- - -
-
- )} - - -
- Attach a poll (optional) - - -

- Fill in a question and at least two options to attach a poll. Leave them all blank - to {editingPoll ? 'detach the existing poll' : 'skip'}. - {editingPoll && · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.} -

- -
- - -
- -
- {pollOptionsForForm.map((val, i) => ( - - ))} -
- -
-
- - -
-
- - -
-
-
- -
- - {editing && Cancel} -
-
-
- -
-

All dispatches

- {dispatches.length === 0 ? ( -

No dispatches yet.

- ) : ( - - - - - - - - - - - - - {dispatches.map(d => ( - - - - - - - - - ))} - -
TitleKindAuthorStatusPublishedActions
{d.title}{dispatchKindLabel(d.kind)}{d.author_name}{STATUS_LABEL[d.status]}{d.published_at ? fmtDateTime(d.published_at) : '—'} - Edit - {d.status === 'draft' && ( -
- - - -
- )} - {d.status === 'published' && ( -
- - - -
- )} -
- - - -
-
- )} -
- -
- - diff --git a/src/components/admin/EventsTab.astro b/src/components/admin/EventsTab.astro deleted file mode 100644 index 2af5188..0000000 --- a/src/components/admin/EventsTab.astro +++ /dev/null @@ -1,218 +0,0 @@ ---- -import type { Event } from '../../lib/db'; -import { fmtDateTime } from '../../lib/markdown'; - -interface Props { - events: Event[]; - editing: Event | null; - viewing: Event | null; - viewingRsvps: { going: number; interested: number; declined: number } | null; -} - -const { events, editing, viewing, viewingRsvps } = Astro.props; - -const KIND_LABEL = { - dinner: 'Dinner', - office_hours: 'Studio hours', - summit: 'Summit', - virtual: 'Virtual', - working_session: 'Working session', -} as const; - -function toInputValue(sql: string | null | undefined): string { - if (!sql) return ''; - return sql.replace(' ', 'T').slice(0, 16); -} - -const formAction = editing ? 'update_event' : 'create_event'; ---- -
- - {viewing && viewingRsvps ? ( -
- ← Back to events -

RSVPs — {viewing.title}

-

{fmtDateTime(viewing.starts_at)} · {viewing.location}

-
-
Going
{viewingRsvps.going}
-
Interested
{viewingRsvps.interested}
-
Declined
{viewingRsvps.declined}
-
-
- ) : ( - <> -
-

{editing ? 'Edit event' : 'New event'}

-
- - {editing && } - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
- -
- - {editing && Cancel} -
-
-
- -
-

All events

- {events.length === 0 ? ( -

No events yet.

- ) : ( - - - - - - - - - - - - {events.map(ev => ( - - - - - - - - ))} - -
TitleKindWhenLocationActions
{ev.title}{KIND_LABEL[ev.kind]}{fmtDateTime(ev.starts_at)}{ev.location || '—'} - RSVPs - Edit -
- - - -
-
- )} -
- - )} - -
- - diff --git a/src/components/admin/PulsesTab.astro b/src/components/admin/PulsesTab.astro deleted file mode 100644 index 2590c6a..0000000 --- a/src/components/admin/PulsesTab.astro +++ /dev/null @@ -1,261 +0,0 @@ ---- -import type { PulseRow, PulseWithCounts } from '../../lib/db'; -import { fmtDateTime } from '../../lib/markdown'; - -interface Props { - pulses: PulseRow[]; - editing: PulseRow | null; - viewing: PulseWithCounts | null; -} - -const { pulses, editing, viewing } = Astro.props; - -const STATUS_LABEL: Record = { - draft: 'Draft', - open: 'Open', - closed: 'Closed', -}; - -/** Convert SQL UTC date "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM" for datetime-local input. */ -function toInputValue(sql: string | null | undefined): string { - if (!sql) return ''; - return sql.replace(' ', 'T').slice(0, 16); -} - -const formAction = editing ? 'update_pulse' : 'create_pulse'; -const optionsForForm: string[] = editing ? [...editing.options] : []; -while (optionsForForm.length < 4) optionsForForm.push(''); ---- -
- - {viewing ? ( - -
- ← Back to pulses -

Results — {STATUS_LABEL[viewing.status]}

-

{viewing.question}

- {viewing.context &&

{viewing.context}

} -

Open {fmtDateTime(viewing.opens_at)} → {fmtDateTime(viewing.closes_at)} · {viewing.votes_total} vote{viewing.votes_total === 1 ? '' : 's'}

-
- {viewing.options.map((opt, i) => { - const count = viewing.votes_by_option[i] ?? 0; - const pct = viewing.votes_total > 0 ? (count / viewing.votes_total) * 100 : 0; - return ( -
-
- {String.fromCharCode(65 + i)} - {opt} - {count} ({pct.toFixed(0)}%) -
-
-
- ); - })} -
-
- ) : ( - <> - -
-

{editing ? 'Edit pulse' : 'New pulse'}

-
- - {editing && } - -
- - -
- -
- - -
- -
- Options (2–4) - {optionsForForm.map((val, i) => ( - - ))} -
- -
-
- - -
-
- - -
-
- -
- - {!editing && ( - - )} - {editing && ( - Cancel - )} -
-
-
- - -
-

All pulses

- {pulses.length === 0 ? ( -

No pulses yet.

- ) : ( - - - - - - - - - - - {pulses.map(p => ( - - - - - - - ))} - -
QuestionStatusOpens / ClosesActions
{p.question}{STATUS_LABEL[p.status]}{fmtDateTime(p.opens_at)} →
{fmtDateTime(p.closes_at)}
- Results - Edit - {p.status === 'draft' && ( -
- - - -
- )} - {p.status === 'open' && ( -
- - - -
- )} -
- - - -
-
- )} -
- - )} - -
- - diff --git a/src/components/admin/RoadmapTab.astro b/src/components/admin/RoadmapTab.astro deleted file mode 100644 index db42c6b..0000000 --- a/src/components/admin/RoadmapTab.astro +++ /dev/null @@ -1,202 +0,0 @@ ---- -import type { RoadmapItemWithAttribution, UserPublic } from '../../lib/db'; - -interface Props { - items: RoadmapItemWithAttribution[]; - editing: RoadmapItemWithAttribution | null; - cabUsers: UserPublic[]; -} - -const { items, editing, cabUsers } = Astro.props; - -const STATUS_LABEL = { - shipping: 'Shipping', - in_beta: 'In beta', - exploring: 'Exploring', - considering: 'Considering', -} as const; - -const formAction = editing ? 'update_roadmap' : 'create_roadmap'; -const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id)); - -// Group items by status for display -type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering'; -const grouped: Record = { - shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order), - in_beta: items.filter(i => i.status === 'in_beta' ).sort((a,b) => a.display_order - b.display_order), - exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order), - considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order), -}; ---- -
- - -
-

{editing ? 'Edit roadmap item' : 'New roadmap item'}

-
- - {editing && } - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
- -
- - - A short narrative cue shown on hover in /roadmap. Optional. -
- -
- Attributed members (who shaped this) - {cabUsers.map(u => ( - - ))} - {cabUsers.length === 0 && No council members yet.} -
- -
- - {editing && Cancel} -
-
-
- - - {(['shipping','in_beta','exploring','considering'] as const).map(status => ( -
-

{STATUS_LABEL[status]} · {grouped[status].length}

- {grouped[status].length === 0 ? ( -

Nothing here yet.

- ) : ( - - - - - - - - - - - - {grouped[status].map((item, idx) => ( - - - - - - - - ))} - -
TitleTargetAttributedOrderActions
{item.title}{item.target ?? '—'}{item.attributed.length === 0 ? '—' : item.attributed.map(a => a.name.split(' ')[0]).join(', ')}{item.display_order} - {idx > 0 && ( -
- - - - -
- )} - {idx < grouped[status].length - 1 && ( -
- - - - -
- )} - Edit -
- - - -
-
- )} -
- ))} - -
- - diff --git a/src/components/admin/UserEditTab.astro b/src/components/admin/UserEditTab.astro deleted file mode 100644 index 10ba806..0000000 --- a/src/components/admin/UserEditTab.astro +++ /dev/null @@ -1,90 +0,0 @@ ---- -import type { UserPublic } from '../../lib/db'; -import { readFocusTags } from '../../lib/format'; - -interface Props { - member: UserPublic; -} - -const { member } = Astro.props; -const tagsStr = readFocusTags(member.focus_tags).join(', '); ---- -
-
- ← Back to participants -

Edit member — {member.name}

- -
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - - {(member.pull_quote ?? '').length} / 200 -
- -
- - Cancel -
-
- -

- Role transitions and deactivation live in the participants table. - A member-number is allocated the first time a user becomes CAB and is never reused. -

-
-
- - - - diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 673b140..1428e41 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -1,607 +1,17 @@ --- -import AppLayout from '../../layouts/AppLayout.astro'; -import { - getAllInvites, getAllUsersPublic, revokeInvite, - createInvite, updateUserRole, deactivateUser, updateUserAdminFields, - getUserPublicById, getAllJoinRequests, - createPulse, updatePulse, publishPulse, closePulse, deletePulse, - getAllPulses, getPulseById, getPulseWithCounts, - createRoadmapItem, updateRoadmapItem, deleteRoadmapItem, - setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, - createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug, - getEventRsvpCount, getEventById, - createDispatch, updateDispatch, publishDispatch, archiveDispatch, - deleteDispatch, getAllDispatchesForAdmin, getDispatchById, - recordActivity, getAllActivityForAdmin, -} from '../../lib/db'; -import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; -import { fmtDate } from '../../lib/markdown'; -import { parseFocusTags } from '../../lib/format'; -import { notifyPulseOpened } from '../../lib/notify'; -import PulsesTab from '../../components/admin/PulsesTab.astro'; -import RoadmapTab from '../../components/admin/RoadmapTab.astro'; -import EventsTab from '../../components/admin/EventsTab.astro'; -import ActivityTab from '../../components/admin/ActivityTab.astro'; -import DispatchesTab from '../../components/admin/DispatchesTab.astro'; -import UserEditTab from '../../components/admin/UserEditTab.astro'; -import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db'; -import '../../admin/admin.css'; +/* --------------------------------------------------------------------------- + * /admin — redirect to the first registered resource. + * + * Auth-gated like every other admin page. Members hitting /admin without + * the fenja role land on /; admins land on the dispatches list view (the + * default Backstage home). + * ------------------------------------------------------------------------- */ + +import { groups } from '../../admin/resources'; const user = Astro.locals.user; +if (user.role !== 'fenja') return Astro.redirect('/'); -// Guard: fenja only -if (user.role !== 'fenja') { - return Astro.redirect('/'); -} - -const tab = Astro.url.searchParams.get('tab') ?? 'invitations'; - -let newInviteToken: string | null = null; -let formError: string | null = null; -let actionMsg: string | null = null; - -if (Astro.request.method === 'POST') { - const data = await Astro.request.formData(); - const action = String(data.get('action') ?? ''); - - if (action === 'create_invite') { - const name = String(data.get('name') ?? '').trim(); - const email = String(data.get('email') ?? '').trim().toLowerCase(); - const organisation = String(data.get('organisation') ?? '').trim(); - const role = String(data.get('role') ?? '') as Role; - - if (!name || !email || !organisation || !['pilot','cab','fenja'].includes(role)) { - formError = 'All fields are required.'; - } else { - const { token, tokenHash } = generateInviteToken(); - createInvite({ - token_hash: tokenHash, - email, - name, - organisation, - role, - expires_at: inviteExpiresAt(), - created_by_user_id: user.id, - }); - newInviteToken = `${Astro.url.origin}/invite/${token}`; - } - } else if (action === 'revoke_invite') { - const id = Number(data.get('invite_id')); - if (id) revokeInvite(id); - return Astro.redirect('/admin?tab=invitations&msg=revoked'); - } else if (action === 'change_role') { - const userId = Number(data.get('user_id')); - const newRole = String(data.get('role')) as Role; - if (userId && ['pilot','cab','fenja'].includes(newRole)) { - updateUserRole(userId, newRole); - } - return Astro.redirect('/admin?tab=participants&msg=updated'); - } else if (action === 'deactivate_user') { - const userId = Number(data.get('user_id')); - if (userId && userId !== user.id) deactivateUser(userId); - return Astro.redirect('/admin?tab=participants&msg=deactivated'); - - // ── User profile edit (title / pull_quote / focus_tags) ───── - } else if (action === 'update_user_admin') { - const userId = Number(data.get('user_id')); - if (userId) { - const title = String(data.get('title') ?? '').trim() || null; - const pullQuote = String(data.get('pull_quote') ?? '').trim() || null; - const tagsInput = String(data.get('focus_tags') ?? ''); - const focusTags = parseFocusTags(tagsInput); - updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags }); - } - return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`); - - // ── Dispatches ─────────────────────────────────────────────── - } else if (action === 'create_dispatch' || action === 'update_dispatch') { - const title = String(data.get('title') ?? '').trim(); - const body = String(data.get('body') ?? ''); - const excerpt = String(data.get('excerpt') ?? '').trim() || null; - const kind = String(data.get('kind') ?? '') as DispatchKind; - const authorId = Number(data.get('author_id')); - const status = String(data.get('status') ?? 'draft') as DispatchStatus; - - // Parse optional poll attachment fields. - const pollExplicit = String(data.get('poll_explicit') ?? '') === '1'; - const pollQuestion = String(data.get('poll_question') ?? '').trim(); - const pollOpts = [0, 1, 2, 3] - .map(i => String(data.get(`poll_option_${i}`) ?? '').trim()) - .filter(s => s.length > 0); - const pollOpens = String(data.get('poll_opens_at') ?? ''); - const pollCloses = String(data.get('poll_closes_at') ?? ''); - let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null; - if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) { - pollInput = { - question: pollQuestion, - options: pollOpts, - opens_at: toSqlDate(pollOpens), - closes_at: toSqlDate(pollCloses), - }; - } - - if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) { - formError = 'Title, body, and a valid kind are required.'; - } else if (action === 'create_dispatch') { - createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput }); - return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created'); - } else { - const id = Number(data.get('dispatch_id')); - if (id) updateDispatch(id, { - title, body, excerpt, kind, author_id: authorId || user.id, - poll: pollInput, pollExplicit, - }); - return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`); - } - } else if (action === 'publish_dispatch') { - const id = Number(data.get('dispatch_id')); - if (id) publishDispatch(id); - return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published'); - } else if (action === 'archive_dispatch') { - const id = Number(data.get('dispatch_id')); - if (id) archiveDispatch(id); - return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived'); - } else if (action === 'delete_dispatch') { - const id = Number(data.get('dispatch_id')); - if (id) deleteDispatch(id); - return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted'); - - // ── Pulses ─────────────────────────────────────────────────── - } else if (action === 'create_pulse' || action === 'update_pulse') { - const question = String(data.get('question') ?? '').trim(); - const context = String(data.get('context') ?? '').trim() || null; - const opens_at = toSqlDate(String(data.get('opens_at') ?? '')); - const closes_at = toSqlDate(String(data.get('closes_at') ?? '')); - const publish = String(data.get('publish') ?? '') === '1'; - const options = [0, 1, 2, 3] - .map(i => String(data.get(`option_${i}`) ?? '').trim()) - .filter(s => s.length > 0); - - if (!question || options.length < 2 || !opens_at || !closes_at) { - formError = 'Question, at least 2 options, and both dates are required.'; - } else if (action === 'create_pulse') { - const id = createPulse({ - question, context, options, opens_at, closes_at, - status: publish ? 'open' : 'draft', - created_by: user.id, - }); - if (publish) { - recordActivity(user.id, 'pulse_opened', 'pulse', id); - const p = getPulseById(id); - if (p) notifyPulseOpened(p); - } - return Astro.redirect('/admin?tab=pulses&msg=pulse_created'); - } else { - const id = Number(data.get('pulse_id')); - if (id) updatePulse(id, { question, context, options, opens_at, closes_at }); - return Astro.redirect('/admin?tab=pulses&msg=pulse_updated'); - } - } else if (action === 'publish_pulse') { - const id = Number(data.get('pulse_id')); - if (id) { - publishPulse(id); - recordActivity(user.id, 'pulse_opened', 'pulse', id); - const p = getPulseById(id); - if (p) notifyPulseOpened(p); - } - return Astro.redirect('/admin?tab=pulses&msg=pulse_published'); - } else if (action === 'close_pulse') { - const id = Number(data.get('pulse_id')); - if (id) closePulse(id); - return Astro.redirect('/admin?tab=pulses&msg=pulse_closed'); - } else if (action === 'delete_pulse') { - const id = Number(data.get('pulse_id')); - if (id) deletePulse(id); - return Astro.redirect('/admin?tab=pulses&msg=pulse_deleted'); - - // ── Roadmap ────────────────────────────────────────────────── - } else if (action === 'create_roadmap' || action === 'update_roadmap') { - const title = String(data.get('title') ?? '').trim(); - const description = String(data.get('description') ?? '').trim(); - const status = String(data.get('status') ?? '') as RoadmapStatus; - const target = String(data.get('target') ?? '').trim() || null; - const displayOrder = Number(data.get('display_order') ?? 0); - const metadataText = String(data.get('metadata_text') ?? '').trim() || null; - const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); - - if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) { - formError = 'Title and status are required.'; - } else if (action === 'create_roadmap') { - const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText }); - setRoadmapAttributions(id, attributedIds); - if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); - return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created'); - } else { - const id = Number(data.get('roadmap_id')); - if (id) { - const { shippedNow } = updateRoadmapItem(id, { - title, description, status, target, display_order: displayOrder, metadata_text: metadataText, - }); - setRoadmapAttributions(id, attributedIds); - if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); - } - return Astro.redirect('/admin?tab=roadmap&msg=roadmap_updated'); - } - } else if (action === 'delete_roadmap') { - const id = Number(data.get('roadmap_id')); - if (id) deleteRoadmapItem(id); - return Astro.redirect('/admin?tab=roadmap&msg=roadmap_deleted'); - } else if (action === 'move_roadmap') { - const id = Number(data.get('roadmap_id')); - const dir = String(data.get('direction') ?? ''); - if (id && (dir === 'up' || dir === 'down')) { - moveRoadmapItem(id, dir); - } - return Astro.redirect('/admin?tab=roadmap&msg=roadmap_moved'); - - // ── Events ─────────────────────────────────────────────────── - } else if (action === 'create_event' || action === 'update_event') { - const slug = String(data.get('slug') ?? '').trim().toLowerCase(); - const title = String(data.get('title') ?? '').trim(); - const kind = String(data.get('kind') ?? '') as EventKind; - const description = String(data.get('description') ?? '').trim(); - const location = String(data.get('location') ?? '').trim(); - const starts_at = toSqlDate(String(data.get('starts_at') ?? '')); - const ends_at = String(data.get('ends_at') ?? '').trim() - ? toSqlDate(String(data.get('ends_at') ?? '')) - : null; - const capacity = Number(data.get('capacity') ?? 0) || null; - const photo_url = String(data.get('photo_url') ?? '').trim() || null; - - if (!slug || !title || !starts_at || !['dinner','office_hours','summit','virtual'].includes(kind)) { - formError = 'Slug, title, kind, and start date are required.'; - } else if (action === 'create_event') { - createEvent({ slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, created_by: user.id }); - return Astro.redirect('/admin?tab=events&msg=event_created'); - } else { - const id = Number(data.get('event_id')); - if (id) updateEvent(id, { title, kind, description, location, starts_at, ends_at, capacity, photo_url }); - return Astro.redirect('/admin?tab=events&msg=event_updated'); - } - } else if (action === 'delete_event') { - const id = Number(data.get('event_id')); - if (id) deleteEvent(id); - return Astro.redirect('/admin?tab=events&msg=event_deleted'); - } -} - -/** "2026-05-11T12:00" (datetime-local input) → "2026-05-11 12:00:00" (SQL UTC). */ -function toSqlDate(input: string): string { - if (!input) return ''; - // datetime-local format: YYYY-MM-DDTHH:MM (no timezone). Treat as UTC. - return input.replace('T', ' ') + (input.length === 16 ? ':00' : ''); -} - -/** Swap display_order with the neighbour in the same status column. */ -function moveRoadmapItem(id: number, dir: 'up' | 'down'): void { - const all = getAllRoadmapItems(); - const item = all.find(r => r.id === id); - if (!item) return; - const sameStatus = all - .filter(r => r.status === item.status) - .sort((a, b) => a.display_order - b.display_order || a.id - b.id); - const idx = sameStatus.findIndex(r => r.id === id); - const swapIdx = dir === 'up' ? idx - 1 : idx + 1; - if (swapIdx < 0 || swapIdx >= sameStatus.length) return; - const other = sameStatus[swapIdx]; - updateRoadmapItem(item.id, { - title: item.title, description: item.description, status: item.status, - target: item.target, display_order: other.display_order, metadata_text: item.metadata_text, - }); - updateRoadmapItem(other.id, { - title: other.title, description: other.description, status: other.status, - target: other.target, display_order: item.display_order, metadata_text: other.metadata_text, - }); -} - -const invites = getAllInvites(); -const users = getAllUsersPublic(); -const joinRequests = getAllJoinRequests(); - -const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; -const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null; - -const fenjaUsers = users.filter(u => u.role === 'fenja'); -const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null; - -const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : []; -const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null; -const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null; - -// Per-tab data -const pulses = tab === 'pulses' ? getAllPulses() : []; -const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null; -const pulseViewing = tab === 'pulses' && viewId ? getPulseWithCounts(viewId, user.id) : null; - -const roadmapItems = tab === 'roadmap' ? getAllRoadmapItems() : []; -const roadmapEditing = tab === 'roadmap' && editId ? getRoadmapItem(editId) : null; -const cabUsers = tab === 'roadmap' ? users.filter(u => u.role === 'cab' || u.role === 'pilot') : []; - -const events = tab === 'events' ? getAllEvents() : []; -const eventEditing = tab === 'events' && editId ? getEventById(editId) : null; -const eventViewing = tab === 'events' && viewId ? getEventById(viewId) : null; -const eventViewingRsvps = tab === 'events' && viewId && eventViewing - ? getEventRsvpCount(eventViewing.slug) - : null; - -const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : []; - -const MSGS: Record = { - revoked: 'Invite revoked.', - updated: 'Role updated.', - deactivated: 'User deactivated.', - user_updated: 'Member profile updated.', - pulse_created: 'Pulse saved.', - pulse_updated: 'Pulse updated.', - pulse_published: 'Pulse published — members notified.', - pulse_closed: 'Pulse closed.', - pulse_deleted: 'Pulse deleted.', - roadmap_created: 'Roadmap item saved.', - roadmap_updated: 'Roadmap item updated.', - roadmap_deleted: 'Roadmap item deleted.', - roadmap_moved: 'Roadmap reordered.', - event_created: 'Event saved.', - event_updated: 'Event updated.', - event_deleted: 'Event deleted.', - dispatch_created: 'Dispatch saved.', - dispatch_updated: 'Dispatch updated.', - dispatch_published: 'Dispatch published.', - dispatch_archived: 'Dispatch archived.', - dispatch_deleted: 'Dispatch deleted.', -}; -actionMsg = Astro.url.searchParams.get('msg'); +const first = groups.flatMap((g) => g.resources)[0]; +return Astro.redirect(first ? `/admin/${first.key}` : '/'); --- - -
- - - - - - - {actionMsg && ( -

- {MSGS[actionMsg] ?? ''} -

- )} - - {formError && ( - - )} - - - {tab === 'invitations' && ( -
- - {/* New invite form */} -
-

Generate invite link

- - {formError && ( - - )} - - {newInviteToken && ( -
-

Copy this link and send it personally. It expires in 14 days and is single-use.

- -
- )} - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - {/* Invite table */} -
-

Outstanding invites

- {invites.filter((i) => !i.used_at).length === 0 ? ( -

No outstanding invites.

- ) : ( - - - - - - - - - - - - - {invites.filter((i) => !i.used_at).map((invite) => ( - - - - - - - - - ))} - -
NameEmailOrganisationRoleExpiresAction
{invite.name}{invite.email}{invite.organisation}{invite.role}{fmtDate(invite.expires_at)} -
- - - -
-
- )} -
- -
- )} - - - {tab === 'participants' && editingUser && ( - - )} - - {tab === 'participants' && !editingUser && ( -
-
-

All participants

- - - - - - - - - - - - - {users.map((u) => ( - - - - - - - - - ))} - -
NameEmailOrganisationRoleLast seenActions
{u.name}{u.email}{u.organisation} - {u.id !== user.id ? ( -
- - - -
- ) : ( - {u.role} - )} -
- {u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'} - - Edit - {u.id !== user.id && ( -
- - - -
- )} -
-
-
- )} - - - {tab === 'join' && ( -
-
-

Join requests

-

- Users who clicked "I want to join" on the home page. Use this to prioritise - follow-up and generate invite links. -

- {joinRequests.length === 0 ? ( -

No join requests yet.

- ) : ( - - - - - - - - - - - {joinRequests.map((jr) => ( - - - - - - - ))} - -
NameEmailOrganisationRequested
{jr.user_name}{jr.user_email}{jr.user_organisation}{fmtDate(jr.created_at)}
- )} -
-
- )} - - {tab === 'pulses' && ( - - )} - - {tab === 'roadmap' && ( - - )} - - {tab === 'events' && ( - - )} - - {tab === 'activity' && ( - - )} - - {tab === 'dispatches' && ( - - )} - -
-
diff --git a/src/pages/admin/preview.astro b/src/pages/admin/preview.astro deleted file mode 100644 index e70535f..0000000 --- a/src/pages/admin/preview.astro +++ /dev/null @@ -1,188 +0,0 @@ ---- -/* --------------------------------------------------------------------------- - * /admin/preview — temporary smoke route for the Backstage shell. - * - * Inline sample dispatches resource exercises the list view + edit panel - * (every field kind is represented). Deleted in step 11 when the new admin - * replaces the old. - * ------------------------------------------------------------------------- */ - -import AdminLayout from '../../admin/components/AdminLayout.astro'; -import ResourceListView from '../../admin/components/ResourceListView.astro'; -import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro'; -import { - getAllDispatchesForAdmin, - getDispatchById, - type DispatchWithAuthor, -} from '../../lib/db'; -import type { Resource, ResourceGroup } from '../../admin/resource-types'; - -const user = Astro.locals.user; - -if (user.role !== 'fenja') { - return Astro.redirect('/'); -} - -// Inline preview resource — exercises every field kind so the panel can -// be eyeballed in isolation. Step 8 ships the production dispatches config. -const dispatchesPreview: Resource = { - key: 'dispatches', - label: 'Dispatches', - pluralLabel: 'Dispatches', - singularLabel: 'Dispatch', - groupKey: 'publishing', - description: 'Updates, decisions, notes — the public record of pilot progress.', - list: { - queryFn: () => getAllDispatchesForAdmin() as unknown as Record[], - columns: [ - { - key: 'title', - label: 'Title', - primary: true, - width: '2fr', - render: (item) => ({ - title: String(item.title ?? ''), - subtitle: `${item.author_name ?? 'Unknown'}`, - }), - }, - { - key: 'kind', - label: 'Kind', - kind: 'pill', - width: '140px', - pillVariants: { - decision: { label: 'Decision', class: 'pill-decision' }, - update: { label: 'Update', class: 'pill-update' }, - note: { label: 'Note', class: 'pill-note' }, - behind_the_scenes: { label: 'Behind the scenes', class: 'pill-bts' }, - }, - }, - { - key: 'status', - label: 'Status', - kind: 'pill', - width: '110px', - pillVariants: { - draft: { label: 'Draft', class: 'pill-draft' }, - published: { label: 'Published', class: 'pill-published' }, - archived: { label: 'Archived', class: 'pill-archived' }, - }, - }, - { - key: 'updated_at', - label: 'Updated', - kind: 'relative-date', - width: '110px', - emptyFallback: '—', - }, - ], - filters: [ - { key: 'all', label: 'All', predicate: () => true, isDefault: true }, - { key: 'published', label: 'Published', predicate: (i) => i.status === 'published' }, - { key: 'drafts', label: 'Drafts', predicate: (i) => i.status === 'draft' }, - { key: 'archived', label: 'Archived', predicate: (i) => i.status === 'archived' }, - ], - search: { - placeholder: 'Search by title or body…', - fields: ['title', 'body'], - }, - defaultSort: { key: 'updated_at', direction: 'desc' }, - pageSize: 10, - }, - form: { - fields: [ - { key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 }, - { - key: 'kind', - label: 'Kind', - kind: 'select', - options: [ - { value: 'decision', label: 'Decision' }, - { value: 'update', label: 'Update' }, - { value: 'note', label: 'Note' }, - { value: 'behind_the_scenes', label: 'Behind the scenes' }, - ], - defaultValue: 'note', - }, - { - key: 'excerpt', - label: 'Excerpt', - kind: 'textarea', - rows: 4, - helperText: - 'Two to four sentences. First sentence becomes the lead paragraph on the dispatch banner; the rest follows in muted text.', - }, - { - key: 'body', - label: 'Body (markdown)', - kind: 'markdown', - rows: 14, - required: true, - }, - { - key: 'status', - label: 'Status on save', - kind: 'select', - options: [ - { value: 'draft', label: 'Draft (hidden from members)' }, - { value: 'published', label: 'Published (visible immediately)' }, - { value: 'archived', label: 'Archived (hidden from members)' }, - ], - defaultValue: 'draft', - }, - ], - embeds: [ - { key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }, - ], - }, - ops: { - getById: (id: number) => - getDispatchById(id) as unknown as Record | null, - delete: () => undefined, // surfaces the Delete button in the panel - }, - actions: [ - { - key: 'publish', - label: 'Publish now', - visibleWhen: (item) => (item as DispatchWithAuthor).status === 'draft', - confirmText: 'Publish this dispatch to all members?', - handler: () => undefined, - }, - { - key: 'archive', - label: 'Archive', - visibleWhen: (item) => (item as DispatchWithAuthor).status === 'published', - destructive: true, - confirmText: 'Archive this dispatch? It will be hidden from members.', - handler: () => undefined, - }, - ], -}; - -const previewGroups: ResourceGroup[] = [ - { key: 'publishing', label: 'Publishing', resources: [dispatchesPreview] }, - { key: 'council', label: 'The council', resources: [] }, - { key: 'system', label: 'System', resources: [] }, -]; - -// Panel state from URL -const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; -const isNew = Astro.url.searchParams.get('new') === '1'; -const showPanel = isNew || editId !== null; - -const editingItem = - editId !== null - ? ((await dispatchesPreview.ops.getById?.(editId)) ?? null) - : null; ---- - - - - {showPanel && ( - - )} - diff --git a/tests/admin-resources.test.ts b/tests/admin-resources.test.ts new file mode 100644 index 0000000..bce6ab1 --- /dev/null +++ b/tests/admin-resources.test.ts @@ -0,0 +1,168 @@ +/* --------------------------------------------------------------------------- + * Verifier for the Backstage admin resource registry. + * + * Walks every registered resource and asserts the invariants that keep the + * shared components renderable. Compile-time TypeScript already catches most + * shape issues via the strict Resource generic — this suite covers what + * TS can't see at the value level (function-ness of handlers, kind strings + * actually being in the registered set, sentinel resource keys not colliding). + * + * Note on "every column.key is a valid field on the entity": + * That's a structural assertion best enforced at compile time. Resource + * narrows the render/value callbacks to the entity's keys; this suite skips + * trying to re-check it at runtime. + * ------------------------------------------------------------------------- */ + +import { describe, it, expect } from 'vitest'; +import { groups } from '../src/admin/resources'; +import type { + Field, + Resource, + ResourceGroup, + Column, + FormEmbed, +} from '../src/admin/resource-types'; + +const KNOWN_FIELD_KINDS: ReadonlySet = new Set([ + 'text', + 'textarea', + 'markdown', + 'select', + 'select-async', + 'multi-select-async', + 'multi-text', + 'date', + 'datetime', + 'number', + 'readonly', +]); + +const KNOWN_COLUMN_KINDS: ReadonlySet = new Set([ + 'text', 'pill', 'relative-date', 'number', 'tag-list', +]); + +const KNOWN_EMBED_COMPONENTS: ReadonlySet = new Set([ + 'pulse-sub-form', +]); + +function allResources(): Resource[] { + return groups.flatMap((g: ResourceGroup) => g.resources as Resource[]); +} + +describe('admin resource registry', () => { + it('has at least one group with resources registered', () => { + expect(groups.length).toBeGreaterThan(0); + expect(allResources().length).toBeGreaterThan(0); + }); + + it('every resource key is unique across the registry', () => { + const keys = allResources().map((r) => r.key); + const dups = keys.filter((k, i) => keys.indexOf(k) !== i); + expect(dups).toEqual([]); + }); + + it('every resource.groupKey points at a real group', () => { + const groupKeys = new Set(groups.map((g) => g.key)); + for (const r of allResources()) { + expect(groupKeys.has(r.groupKey), `${r.key} → unknown groupKey ${r.groupKey}`).toBe(true); + } + }); + + describe.each(allResources())('resource: $key', (resource: Resource) => { + it('has required identity fields', () => { + expect(resource.key).toBeTruthy(); + expect(resource.label).toBeTruthy(); + expect(resource.pluralLabel).toBeTruthy(); + expect(resource.singularLabel).toBeTruthy(); + expect(resource.groupKey).toBeTruthy(); + }); + + it('list.queryFn is a function', () => { + expect(typeof resource.list.queryFn).toBe('function'); + }); + + it('every column has a registered kind (or none = text)', () => { + for (const col of resource.list.columns as Column[]) { + const kind = col.kind ?? 'text'; + expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: column ${col.key} → unknown kind ${kind}`).toBe(true); + } + if (resource.list.columnsByFilter) { + for (const [filterKey, cols] of Object.entries(resource.list.columnsByFilter)) { + for (const col of cols as Column[]) { + const kind = col.kind ?? 'text'; + expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: columnsByFilter.${filterKey}.${col.key} → unknown kind ${kind}`).toBe(true); + } + } + } + }); + + it('exactly one or zero filters is isDefault', () => { + const filters = resource.list.filters ?? []; + const defaults = filters.filter((f) => f.isDefault); + expect(defaults.length).toBeLessThanOrEqual(1); + }); + + it('every filter.predicate is a function', () => { + for (const f of resource.list.filters ?? []) { + expect(typeof f.predicate, `${resource.key}: filter ${f.key} predicate`).toBe('function'); + } + }); + + it('every form field has a registered kind', () => { + for (const field of resource.form?.fields ?? []) { + expect( + KNOWN_FIELD_KINDS.has(field.kind), + `${resource.key}: field ${field.key} → unknown kind ${field.kind}`, + ).toBe(true); + } + }); + + it('every embed.component is in the registered set', () => { + for (const embed of resource.form?.embeds ?? []) { + expect( + KNOWN_EMBED_COMPONENTS.has(embed.component), + `${resource.key}: embed ${embed.key} → unknown component ${embed.component}`, + ).toBe(true); + } + }); + + it('every ops member is a function (when defined)', () => { + const ops = resource.ops; + if (ops.create) expect(typeof ops.create).toBe('function'); + if (ops.update) expect(typeof ops.update).toBe('function'); + if (ops.delete) expect(typeof ops.delete).toBe('function'); + if (ops.getById) expect(typeof ops.getById).toBe('function'); + }); + + it('every action.handler is a function', () => { + for (const action of resource.actions ?? []) { + expect(typeof action.handler, `${resource.key}: action ${action.key}`).toBe('function'); + } + }); + + it('action keys are unique', () => { + const keys = (resource.actions ?? []).map((a) => a.key); + const dups = keys.filter((k, i) => keys.indexOf(k) !== i); + expect(dups).toEqual([]); + }); + + it('renders SOMETHING when an item is clicked (form OR summary, or no clicks)', () => { + // If form is null and there's no summary, the resource is non-clickable. + // If form is null but a summary is defined → review panel renders. + // If form is defined → edit panel renders. + // The only invalid shape is form=null + summary defined + no actions, + // which would render an empty review panel. Flag it. + if (resource.form === null && resource.summary !== undefined) { + const actions = resource.actions ?? []; + expect(actions.length, `${resource.key}: review-mode resource with no actions`).toBeGreaterThan(0); + } + }); + + it('when ops.create is defined, the form is defined too', () => { + // Can't render the create panel without a form. + if (resource.ops.create) { + expect(resource.form, `${resource.key}: ops.create is defined but form is null`).not.toBeNull(); + } + }); + }); +});