--- 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'; const user = Astro.locals.user; // 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; 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 }); 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 }); 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 attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); if (!title || !['shipping','beta','exploring'].includes(status)) { formError = 'Title and status are required.'; } else if (action === 'create_roadmap') { const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder }); 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, }); 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, }); updateRoadmapItem(other.id, { title: other.title, description: other.description, status: other.status, target: other.target, display_order: item.display_order, }); } 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; // 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'); ---
{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) => ( ))}
Name Email Organisation Role Expires Action
{invite.name} {invite.email} {invite.organisation} {invite.role} {fmtDate(invite.expires_at)}
)}
)} {tab === 'participants' && editingUser && ( )} {tab === 'participants' && !editingUser && (

All participants

{users.map((u) => ( ))}
Name Email Organisation Role Last seen Actions
{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) => ( ))}
Name Email Organisation Requested
{jr.user_name} {jr.user_email} {jr.user_organisation} {fmtDate(jr.created_at)}
)}
)} {tab === 'pulses' && ( )} {tab === 'roadmap' && ( )} {tab === 'events' && ( )} {tab === 'activity' && ( )} {tab === 'dispatches' && ( )}