feat: join_requests table and join CTA flow

This commit is contained in:
Jonathan 2026-04-19 20:29:09 +02:00
parent fa5e6d8414
commit 4bed3a5fe0
4 changed files with 112 additions and 2 deletions

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS join_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -329,3 +329,33 @@ export function getAllAttendance(meetingSlug: string): { user_id: number; status
WHERE a.meeting_slug = ? WHERE a.meeting_slug = ?
`).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[]; `).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[];
} }
// ── Join requests ────────────────────────────────────────────────
export function createJoinRequest(userId: number): void {
db.prepare('INSERT OR IGNORE INTO join_requests (user_id) VALUES (?)').run(userId);
}
export function hasJoinRequest(userId: number): boolean {
const r = db.prepare('SELECT id FROM join_requests WHERE user_id = ?').get(userId);
return r !== undefined;
}
export interface JoinRequest {
id: number;
user_id: number;
created_at: string;
user_name: string;
user_email: string;
user_organisation: string;
}
export function getAllJoinRequests(): JoinRequest[] {
return db.prepare(`
SELECT jr.id, jr.user_id, jr.created_at,
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
FROM join_requests jr
JOIN users u ON u.id = jr.user_id
ORDER BY jr.created_at DESC
`).all() as JoinRequest[];
}

View file

@ -3,6 +3,7 @@ import AppLayout from '../../layouts/AppLayout.astro';
import { import {
getAllInvites, getAllUsersPublic, revokeInvite, getAllInvites, getAllUsersPublic, revokeInvite,
createInvite, updateUserRole, deactivateUser, createInvite, updateUserRole, deactivateUser,
getAllJoinRequests,
} from '../../lib/db'; } from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown'; import { fmtDate } from '../../lib/markdown';
@ -64,8 +65,9 @@ if (Astro.request.method === 'POST') {
} }
} }
const invites = getAllInvites(); const invites = getAllInvites();
const users = getAllUsersPublic(); const users = getAllUsersPublic();
const joinRequests = getAllJoinRequests();
actionMsg = Astro.url.searchParams.get('msg'); actionMsg = Astro.url.searchParams.get('msg');
--- ---
<AppLayout title="Admin" user={user}> <AppLayout title="Admin" user={user}>
@ -86,6 +88,12 @@ actionMsg = Astro.url.searchParams.get('msg');
href="/admin?tab=participants" href="/admin?tab=participants"
class:list={['tab label-sm', { active: tab === 'participants' }]} class:list={['tab label-sm', { active: tab === 'participants' }]}
>Participants</a> >Participants</a>
<a
href="/admin?tab=join"
class:list={['tab label-sm', { active: tab === 'join' }]}
>
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
</a>
</div> </div>
{actionMsg && ( {actionMsg && (
@ -255,6 +263,43 @@ actionMsg = Astro.url.searchParams.get('msg');
</div> </div>
)} )}
<!-- Join requests tab -->
{tab === 'join' && (
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">Join requests</h2>
<p class="body-sm section-note">
Users who clicked "I want to join" on the home page. Use this to prioritise
follow-up and generate invite links.
</p>
{joinRequests.length === 0 ? (
<p class="body-sm empty-msg">No join requests yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Requested</th>
</tr>
</thead>
<tbody>
{joinRequests.map((jr) => (
<tr>
<td class="body-sm">{jr.user_name}</td>
<td class="body-sm">{jr.user_email}</td>
<td class="body-sm">{jr.user_organisation}</td>
<td class="body-sm muted">{fmtDate(jr.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)}
</div> </div>
</AppLayout> </AppLayout>
@ -303,6 +348,27 @@ actionMsg = Astro.url.searchParams.get('msg');
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; } .tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
.tab.active { color: var(--on-surface); background: var(--surface-container); } .tab.active { color: var(--on-surface); background: var(--surface-container); }
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--secondary);
color: var(--on-secondary);
border-radius: var(--radius-full);
font-size: var(--text-label-sm);
font-weight: 700;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 var(--space-1);
margin-left: var(--space-2);
}
.section-note {
color: var(--on-surface-muted);
margin: 0;
max-width: var(--reading-max);
}
/* ── Messages ────────────────────────────────────────────────────── */ /* ── Messages ────────────────────────────────────────────────────── */
.action-msg { .action-msg {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);

9
src/pages/api/join.ts Normal file
View file

@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { createJoinRequest } from '../../lib/db';
export const POST: APIRoute = ({ locals }) => {
createJoinRequest(locals.user.id);
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
};