feat: add club members and wall management

This commit is contained in:
Developer 2026-10-01 08:31:45 -04:00
parent d378db4c86
commit f495ec0cac
13 changed files with 1206 additions and 13 deletions

View File

@ -0,0 +1,169 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';
import { collection, deleteDoc, doc, documentId, getDoc, getDocs, limit, orderBy, query, setDoc, startAfter, Timestamp, updateDoc, where, writeBatch } from 'firebase/firestore';
import fs from 'node:fs';
import path from 'node:path';
let env;
const clubId = 'ClubCaseSensitive';
const postPath = `clubs/${clubId}/wall_posts/post1`;
const profile = (uid, role = 'member', status = 'active') => ({ uid, clubId, role, status, displayRank: role === 'owner' ? 'President' : 'Member' });
const post = (uid = 'member', role = 'member') => ({
id: 'post1', clubId, authorUid: uid, authorRole: role, authorDisplayName: uid,
authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'Ride briefing',
createdAt: Timestamp.now(), isPinned: false, fistBumpCount: 0, fistBumpUserIds: [], comments: [],
});
const comment = (uid = 'member', role = 'member') => ({
id: 'reply1', postId: 'post1', authorUid: uid, authorRole: role, authorDisplayName: uid,
authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'See you there', createdAt: new Date().toISOString(),
});
const dbFor = (uid) => env.authenticatedContext(uid).firestore();
beforeAll(async () => {
env = await initializeTestEnvironment({ projectId: 'demo-motorsignal-club-wall',
firestore: { rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8') } });
});
afterAll(async () => { await env?.cleanup(); });
beforeEach(async () => {
await env.clearFirestore();
await env.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await setDoc(doc(db, 'clubs', clubId), { id: clubId, ownerUid: 'owner', memberCount: 4, archivedAt: null, visibility: 'private' });
for (const [uid, role, status] of [['owner', 'owner', 'active'], ['admin', 'admin', 'active'], ['peer', 'admin', 'active'],
['member', 'member', 'active'], ['invited', 'member', 'invited'], ['requested', 'member', 'requested'], ['removed', 'member', 'removed']]) {
await setDoc(doc(db, 'clubs', clubId, 'members', uid), profile(uid, role, status));
}
await setDoc(doc(db, postPath), post());
});
});
describe('Club wall privacy and author authority', () => {
test.each(['owner', 'admin', 'member'])('%s can point-read and subscribe to the club wall query', async (uid) => {
const db = dbFor(uid);
await assertSucceeds(getDoc(doc(db, postPath)));
await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'wall_posts'), orderBy('createdAt', 'desc'), limit(50))));
});
test.each(['outsider', 'invited', 'requested', 'removed'])('%s cannot read or create posts', async (uid) => {
const db = dbFor(uid);
await assertFails(getDoc(doc(db, postPath)));
await assertFails(getDocs(collection(db, 'clubs', clubId, 'wall_posts')));
await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid), id: 'post2' }));
});
test('signed-out and anonymous accounts cannot access posts', async () => {
for (const db of [env.unauthenticatedContext().firestore(), env.authenticatedContext('member', { firebase: { sign_in_provider: 'anonymous' } }).firestore()]) {
await assertFails(getDoc(doc(db, postPath)));
await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' }));
}
});
test('a banned active member cannot read or mutate the wall', async () => {
await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'bans', 'member'), { uid: 'member' }));
const db = dbFor('member');
await assertFails(getDoc(doc(db, postPath)));
await assertFails(deleteDoc(doc(db, postPath)));
await assertFails(updateDoc(doc(db, postPath), { comments: [comment()] }));
});
test('archived clubs deny wall access, even for an owner', async () => {
await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId), { archivedAt: Timestamp.now() }));
await assertFails(getDoc(doc(dbFor('owner'), postPath)));
await assertFails(deleteDoc(doc(dbFor('owner'), postPath)));
});
test('active member can create an unpinned post with the mobile-compatible model', async () => {
await assertSucceeds(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' }));
});
test.each(['owner', 'admin'])('%s can publish a pinned announcement', async (uid) => {
await assertSucceeds(setDoc(doc(dbFor(uid), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid, uid), id: 'post2', isPinned: true }));
});
test.each([
{ authorUid: 'owner' }, { authorRole: 'owner' }, { authorDisplayRank: 'President' },
{ isPinned: true }, { clubId: 'otherClub' }, { content: ' ' }, { content: 'x'.repeat(5001) },
{ comments: [comment()] }, { fistBumpCount: 10 }, { latitude: 1 }, { createdAt: 'today' },
])('rejects forged or malformed create: %j', async (change) => {
await assertFails(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2', ...change }));
});
test('wall access does not grant private ride telemetry or live presence', async () => {
const db = dbFor('admin');
await assertFails(getDoc(doc(db, 'users', 'member', 'rides', 'privateRide')));
await assertFails(getDoc(doc(db, 'group_rides', 'privateRide', 'live_presence', 'member')));
});
});
describe('Replies and moderation preserve existing content and identity', () => {
test('members append replies without rewriting previous comments', async () => {
const ref = doc(dbFor('member'), postPath);
const first = comment();
await assertSucceeds(updateDoc(ref, { comments: [first] }));
await assertSucceeds(updateDoc(ref, { comments: [first, { ...comment(), id: 'reply2' }] }));
expect((await getDoc(ref)).data().comments).toHaveLength(2);
await assertFails(updateDoc(ref, { comments: [{ ...first, content: 'forged edit' }, { ...comment(), id: 'reply3' }, comment()] }));
await assertFails(updateDoc(ref, { comments: [] }));
});
test.each([{ authorUid: 'owner' }, { authorRole: 'admin' }, { postId: 'otherPost' }, { content: '' }, { content: 'x'.repeat(2001) }, { latitude: 1 }])('rejects forged reply: %j', async (change) => {
await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [{ ...comment(), ...change }] }));
});
test('reply limit preserves the full existing thread', async () => {
const replies = Array.from({ length: 200 }, (_, i) => ({ ...comment(), id: `reply${i}` }));
await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), postPath), { comments: replies }));
await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [...replies, comment()] }));
});
test.each(['owner', 'admin'])('%s can pin/unpin and delete another member’s post', async (uid) => {
const ref = doc(dbFor(uid), postPath);
await assertSucceeds(updateDoc(ref, { isPinned: true }));
await assertSucceeds(updateDoc(ref, { isPinned: false }));
await assertSucceeds(deleteDoc(ref));
});
test('members cannot pin or delete someone else’s post, even with an officer display rank', async () => {
await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' }));
const ref = doc(dbFor('member'), postPath);
await assertFails(updateDoc(ref, { isPinned: true }));
});
test('post author may delete their own post; members cannot delete others', async () => {
await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'otherMember'), profile('otherMember')));
await assertFails(deleteDoc(doc(dbFor('otherMember'), postPath)));
await assertSucceeds(deleteDoc(doc(dbFor('member'), postPath)));
});
test.each([{ authorUid: 'owner' }, { createdAt: Timestamp.fromMillis(1) }, { content: 'rewritten' }, { clubId: 'otherClub' }])('even admins cannot rewrite post identity/content: %j', async (change) => {
await assertFails(updateDoc(doc(dbFor('admin'), postPath), change));
});
test('mobile fist bump toggles can change only the caller’s own reaction', async () => {
const ref = doc(dbFor('member'), postPath);
await assertSucceeds(updateDoc(ref, { fistBumpCount: 1, fistBumpUserIds: ['member'] }));
await assertFails(updateDoc(ref, { fistBumpCount: 2, fistBumpUserIds: ['member', 'admin'] }));
await assertSucceeds(updateDoc(ref, { fistBumpCount: 0, fistBumpUserIds: [] }));
});
});
describe('Members management remains transactional and preserves hierarchy', () => {
test('roster pagination uses the active-member query and a document ID cursor', async () => {
const db = dbFor('admin');
const first = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), limit(2))));
const next = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), startAfter(first.docs[1].id), limit(2))));
expect(new Set([...first.docs, ...next.docs].map((item) => item.id)).size).toBe(4);
});
test.each(['owner', 'admin'])('%s can remove an active member with the matching count decrement', async (uid) => {
const db = dbFor(uid);
const batch = writeBatch(db);
batch.update(doc(db, 'clubs', clubId, 'members', 'member'), { status: 'removed', removedAt: Timestamp.now(), updatedAt: Timestamp.now() });
batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: 'member', updatedAt: Timestamp.now() });
await assertSucceeds(batch.commit());
expect((await getDoc(doc(db, 'clubs', clubId, 'members', 'member'))).data().status).toBe('removed');
});
test('removal without a count decrement is denied', async () => {
await assertFails(updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'members', 'member'), { status: 'removed' }));
});
test('admin cannot remove owner or peer admin', async () => {
for (const uid of ['owner', 'peer']) {
const db = dbFor('admin');
const batch = writeBatch(db);
batch.update(doc(db, 'clubs', clubId, 'members', uid), { status: 'removed' });
batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: uid });
await assertFails(batch.commit());
}
});
test('admin can change cosmetic rank without changing the member role', async () => {
const ref = doc(dbFor('admin'), 'clubs', clubId, 'members', 'member');
await assertSucceeds(updateDoc(ref, { displayRank: 'Road Captain', updatedAt: Timestamp.now() }));
expect((await getDoc(ref)).data().role).toBe('member');
await assertFails(updateDoc(ref, { role: 'admin' }));
await assertFails(updateDoc(doc(dbFor('member'), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' }));
});
});

View File

@ -1254,6 +1254,88 @@ service cloud.firestore {
allow delete: if false; allow delete: if false;
} }
// --- CLUB WALL ---
// Uses the existing mobile wall_posts model (embedded comments).
// Membership gives access to club discussions only, never ride presence.
match /wall_posts/{postId} {
function canUseWall() {
return isRegisteredUser()
&& getClubDoc().data.archivedAt == null
&& !isBanned(request.auth.uid)
&& (isCallerClubOwner() || isActiveMember(request.auth.uid));
}
function canModerateWall() {
return isCallerClubOwner() || isClubAdmin(request.auth.uid);
}
function validWallAuthor(data) {
return data.authorUid == request.auth.uid
&& data.authorDisplayName is string
&& data.authorDisplayName.size() > 0 && data.authorDisplayName.size() <= 100
&& ((isCallerClubOwner() && data.authorRole == 'owner')
|| (!isCallerClubOwner() && data.authorRole == memberData(request.auth.uid).role))
&& (!('authorHandle' in data) || (data.authorHandle is string && data.authorHandle.size() <= 100))
&& (!('authorAvatarUrl' in data) || isValidClubImageUrl(data.authorAvatarUrl))
&& (!('authorDisplayRank' in data) || (data.authorDisplayRank is string
&& data.authorDisplayRank == memberData(request.auth.uid).displayRank));
}
function validWallContent(data, maxLength) {
return data.content is string && data.content.trim().size() > 0 && data.content.size() <= maxLength;
}
function validPostCreate() {
let data = request.resource.data;
return data.keys().hasOnly(['id', 'clubId', 'authorUid', 'authorDisplayName',
'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole',
'content', 'createdAt', 'isPinned', 'fistBumpCount', 'fistBumpUserIds', 'comments'])
&& data.id == postId && data.clubId == clubId
&& validWallAuthor(data) && validWallContent(data, 5000)
&& data.createdAt is timestamp
&& data.isPinned is bool && (!data.isPinned || canModerateWall())
&& data.fistBumpCount == 0 && data.fistBumpUserIds == [] && data.comments == [];
}
function validReplyAppend() {
let old = resource.data.comments;
let next = request.resource.data.comments;
let reply = next[next.size() - 1];
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['comments'])
&& old is list && next is list && old.size() < 200
&& next.size() == old.size() + 1 && next == old.concat([reply])
&& reply is map
&& reply.keys().hasOnly(['id', 'postId', 'authorUid', 'authorDisplayName',
'authorHandle', 'authorAvatarUrl', 'authorDisplayRank', 'authorRole', 'content', 'createdAt'])
&& reply.id is string && reply.id.size() > 0 && reply.id.size() <= 100
&& reply.postId == postId && validWallAuthor(reply) && validWallContent(reply, 2000)
// Mobile serializes embedded comment timestamps as ISO strings.
&& reply.createdAt is string && reply.createdAt.size() <= 40
&& reply.createdAt.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*Z$');
}
function validFistBumpToggle() {
let old = resource.data.fistBumpUserIds;
let next = request.resource.data.fistBumpUserIds;
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['fistBumpCount', 'fistBumpUserIds'])
&& next is list && next.size() <= 5000
&& next.size() == next.toSet().size()
&& request.resource.data.fistBumpCount == next.size()
&& ((!(request.auth.uid in old) && next == old.concat([request.auth.uid]))
|| (request.auth.uid in old && next == old.removeAll([request.auth.uid])));
}
allow read: if canUseWall();
allow create: if canUseWall() && validPostCreate();
allow update: if canUseWall() && (
(canModerateWall()
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isPinned'])
&& request.resource.data.isPinned is bool)
|| validReplyAppend() || validFistBumpToggle()
);
allow delete: if canUseWall() && (canModerateWall() || resource.data.authorUid == request.auth.uid);
}
// --- ADMINISTRATIVE BANS SUBCOLLECTION --- // --- ADMINISTRATIVE BANS SUBCOLLECTION ---
match /bans/{bannedUid} { match /bans/{bannedUid} {
allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid); allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);

View File

@ -0,0 +1,9 @@
'use client';
import { use } from 'react';
import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page';
export default function MembersPage({ params }: { params: Promise<{ clubId: string }> }) {
const { clubId } = use(params);
return <FounderCommunityPage clubId={clubId} section="members" />;
}

View File

@ -0,0 +1,9 @@
'use client';
import { use } from 'react';
import { FounderCommunityPage } from '../../../../../components/founder-portal/founder-community-page';
export default function WallPage({ params }: { params: Promise<{ clubId: string }> }) {
const { clubId } = use(params);
return <FounderCommunityPage clubId={clubId} section="wall" />;
}

View File

@ -0,0 +1,28 @@
'use client';
import React, { useEffect, useRef } from 'react';
export function CommunityDialog({ title, busy, onClose, children }: {
title: string; busy: boolean; onClose: () => void; children: React.ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return (
<dialog ref={ref} aria-label={title} onCancel={(event) => {
event.preventDefault();
if (!busy) onClose();
}} className="w-[calc(100%_-_2rem)] max-w-lg rounded-2xl border border-brand-border bg-brand-card p-6 text-on-surface backdrop:bg-black/80">
<h2 className="font-headline text-xl font-bold mb-4">{title}</h2>
{children}
<button type="button" disabled={busy} onClick={onClose}
className="mt-4 px-4 py-2 rounded-lg border border-brand-border disabled:opacity-50">Cancel</button>
</dialog>
);
}
export const communityInput = 'w-full rounded-lg border border-brand-border bg-brand-bg px-3 py-2 text-on-surface focus:outline-none focus:border-brand-cyan';
export const communityButton = 'rounded-lg border border-brand-border px-4 py-2 text-sm hover:border-brand-cyan focus-visible:outline focus-visible:outline-brand-cyan disabled:opacity-50 disabled:cursor-not-allowed';

View File

@ -0,0 +1,61 @@
'use client';
import React, { useEffect, useState } from 'react';
import { onAuthStateChanged } from 'firebase/auth';
import { useRouter, useSearchParams } from 'next/navigation';
import { firebaseAuth } from '../../lib/firebase-client';
import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext } from '../../lib/club-data';
import { FounderPortalShell } from './founder-portal-shell';
import { FounderMembers } from './founder-members';
import { FounderWall } from './founder-wall';
import { OverviewSkeleton } from './skeleton-state';
import { SafeErrorState } from './safe-error-state';
export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' }) {
const router = useRouter();
const searchParams = useSearchParams();
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
const [context, setContext] = useState<FounderClubContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [access, setAccess] = useState<'sign-in' | 'restricted' | null>(null);
const [retry, setRetry] = useState(0);
useEffect(() => {
let generation = 0;
let disposed = false;
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
const current = ++generation;
setContext(null); setLoading(true); setAccess(null); setError(null);
if (!demo && (!user || user.isAnonymous)) {
setAccess('sign-in'); setLoading(false); return;
}
try {
const result = await fetchFounderClubContext(clubId, user?.uid, { allowDemoPreview: demo });
if (disposed || current !== generation) return;
if (!result.permissions.canManageSettings && !result.isDemoFixture) setAccess('restricted');
else setContext(result);
} catch (err) {
if (!disposed && current === generation) setError(sanitizePortalError(err));
} finally {
if (!disposed && current === generation) setLoading(false);
}
});
return () => { disposed = true; generation++; unsubscribe(); };
}, [clubId, demo, retry]);
if (loading) return <div className="min-h-screen bg-brand-bg p-6"><OverviewSkeleton /></div>;
if (access) return <SafeErrorState title={access === 'sign-in' ? 'Authentication Required' : 'Management Access Restricted'}
message={access === 'sign-in' ? 'Sign in with your rider account to manage this club.' : 'Only club Owners and Admins can access this management page.'}
actionLabel={access === 'sign-in' ? 'Sign In' : 'Return to Clubs'} onAction={() => router.push(access === 'sign-in' ? '/login' : '/clubs')} />;
if (error || !context) return <SafeErrorState title="Club information unavailable" message={error || undefined}
onRetry={() => setRetry((value) => value + 1)} actionLabel="Return to Clubs" onAction={() => router.push('/clubs')} />;
return <FounderPortalShell club={context.club} permissions={context.permissions} activeNav={section}
onPostAnnouncement={() => {
if (section === 'wall') document.getElementById('wall-post-content')?.focus();
else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`);
}}>
{section === 'members' ? <FounderMembers key={context.club.id + context.currentUser.uid} context={context} />
: <FounderWall key={context.club.id + context.currentUser.uid} context={context} />}
</FounderPortalShell>;
}

View File

@ -0,0 +1,146 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_CLUB_RANKS, fetchClubMembers, sanitizePortalError, updateMemberRank } from '../../lib/club-data';
import { fetchMemberRoster, removeClubMember } from '../../lib/club-community';
import type { FounderClubContext } from '../../lib/club-data';
import type { ClubMember } from './types';
import { CommunityDialog, communityButton, communityInput } from './community-dialog';
export function FounderMembers({ context }: { context: FounderClubContext }) {
const [members, setMembers] = useState<ClubMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [notice, setNotice] = useState('');
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all');
const [selection, setSelection] = useState<{ member: ClubMember; action: 'rank' | 'remove' } | null>(null);
const [rank, setRank] = useState('');
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [actionError, setActionError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const roster = context.isDemoFixture
? await fetchClubMembers(context.club.id, context.currentUser.uid, { allowDemoPreview: true })
: await fetchMemberRoster(context.club.id);
setMembers(roster);
setHasMore(!context.isDemoFixture && roster.length === 50);
} catch (err) { setError(sanitizePortalError(err, 'Members could not be loaded.')); }
finally { setLoading(false); }
}, [context]);
useEffect(() => { void load(); }, [load]);
async function loadMore() {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
setError(null);
try {
const roster = await fetchMemberRoster(context.club.id, members[members.length - 1]?.uid);
setMembers((current) => [...current, ...roster.filter((member) => !current.some((item) => item.uid === member.uid))]);
setHasMore(roster.length === 50);
} catch (err) { setError(sanitizePortalError(err, 'More members could not be loaded.')); }
finally { setLoadingMore(false); }
}
const term = search.trim().toLowerCase();
const visible = members.filter((member) =>
(filter === 'all' || member.role === filter) &&
[member.displayName, member.handle, member.displayRank, member.uid].some((value) => value?.toLowerCase().includes(term)));
function canRemove(member: ClubMember) {
return !context.isDemoFixture && member.uid !== context.currentUser.uid &&
member.uid !== context.club.ownerUid && member.role !== 'owner' &&
(context.currentUser.role === 'owner' || member.role !== 'admin');
}
async function confirm(event: React.FormEvent) {
event.preventDefault();
if (!selection || busyRef.current || context.isDemoFixture) return;
busyRef.current = true;
setBusy(true);
setActionError(null);
try {
if (selection.action === 'rank') {
await updateMemberRank(context.club.id, selection.member.uid, rank);
setMembers((current) => current.map((member) => member.uid === selection.member.uid
? { ...member, displayRank: rank.trim() } : member));
setNotice('Display rank updated. Administrative access is unchanged.');
} else {
await removeClubMember(context.club.id, selection.member.uid);
setMembers((current) => current.filter((member) => member.uid !== selection.member.uid));
setNotice('Member removed from the club. Membership history is preserved.');
}
setSelection(null);
} catch (err) { setActionError(sanitizePortalError(err, 'The member could not be updated. Refresh the roster and try again.')); }
finally { busyRef.current = false; setBusy(false); }
}
return (
<section className="space-y-6">
<div className="flex flex-wrap gap-4 items-end justify-between">
<div><p className="font-mono text-xs text-brand-cyan mb-2">CLUB MANAGEMENT / ROSTER</p>
<h1 className="font-headline text-3xl font-bold text-white">Members</h1>
<p className="mt-2 text-on-surface-variant">Your active roster, officer ranks, and membership controls.</p></div>
<button className={communityButton} disabled={loading || loadingMore} onClick={() => void load()}>Refresh roster</button>
</div>
{context.isDemoFixture && <p className="text-brand-cyan">Development preview • changes are disabled.</p>}
<div className="grid sm:grid-cols-3 gap-3">
{([['Members loaded', members.length], ['Administrators loaded', members.filter((m) => m.role === 'admin' || m.role === 'owner').length], ['Showing', visible.length]] as const).map(([label, count]) => (
<div key={label} className="rounded-xl border border-brand-border bg-brand-card p-5">
<p className="text-xs text-on-surface-variant">{label}</p><p className="font-mono text-3xl text-brand-cyan mt-2">{loading || error ? '—' : count}</p>
</div>
))}
</div>
{hasMore && <div className="flex flex-wrap gap-3 items-center"><button className={communityButton} disabled={loading || loadingMore} onClick={() => void loadMore()}>{loadingMore ? 'Loading…' : 'Load more members'}</button><p className="text-xs text-outline">Search and counts cover loaded members. Load more to include the rest of your roster.</p></div>}
<div className="rounded-2xl border border-brand-border bg-brand-card overflow-hidden">
<div className="p-4 grid gap-4 sm:grid-cols-[1fr_auto] border-b border-brand-border">
<label className="text-xs text-on-surface-variant">Search roster
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${communityInput} mt-1`} placeholder="Name, handle, rank, or rider ID" /></label>
<label className="text-xs text-on-surface-variant">Administrative role
<select value={filter} onChange={(event) => setFilter(event.target.value)} className={`${communityInput} mt-1`}>
<option value="all">All roles</option><option value="owner">Owner</option><option value="admin">Admin</option><option value="member">Member</option>
</select></label>
</div>
<p role="status" className="px-4 pt-3 text-secondary">{notice}</p>
{loading ? <p role="status" className="p-8">Loading members…</p>
: error ? <p role="alert" className="p-8 text-error">{error}</p>
: visible.length === 0 ? <p className="p-8 text-on-surface-variant">{members.length ? 'No members match your search.' : 'There are no active members to display.'}</p>
: <div className="overflow-x-auto"><table className="w-full text-left">
<thead className="font-mono text-xs text-outline"><tr><th className="p-4">Rider</th><th className="p-4">Display rank</th><th className="p-4">Access</th><th className="p-4">Actions</th></tr></thead>
<tbody>{visible.map((member) => <tr key={member.uid} className="border-t border-brand-border/60">
<td className="p-4"><p className="font-semibold text-white">{member.displayName || 'Rider'}</p><p className="text-xs text-outline break-all">{member.handle || member.uid}</p></td>
<td className="p-4"><span className="inline-block rounded-md border border-brand-cyan/25 bg-brand-cyan/10 px-2 py-1 text-xs text-brand-cyan">{member.displayRank || 'Member'}</span></td>
<td className="p-4 capitalize">{member.role}</td>
<td className="p-4"><div className="flex flex-wrap gap-2">
<button className={communityButton} disabled={context.isDemoFixture} aria-label={`Edit rank for ${member.displayName || member.uid}`} onClick={() => {
setSelection({ member, action: 'rank' }); setRank(member.displayRank || 'Member'); setActionError(null);
}}>Edit rank</button>
{canRemove(member) && <button className={`${communityButton} text-error`} aria-label={`Remove ${member.displayName || member.uid}`} onClick={() => {
setSelection({ member, action: 'remove' }); setActionError(null);
}}>Remove</button>}
</div></td>
</tr>)}</tbody>
</table></div>}
</div>
<p className="text-xs text-outline">Display ranks are club titles. Owner and Admin roles control administrative access.</p>
{selection && <CommunityDialog title={selection.action === 'rank' ? 'Edit display rank' : 'Remove member'} busy={busy} onClose={() => setSelection(null)}>
<form onSubmit={confirm}>
<p className="mb-4 text-on-surface-variant">{selection.member.displayName || selection.member.uid}</p>
{selection.action === 'rank' ? <>
<label className="block mb-3">Display rank<input autoFocus required maxLength={60} list="club-display-ranks" value={rank} onChange={(event) => setRank(event.target.value)} className={`${communityInput} mt-2`} /></label>
<datalist id="club-display-ranks">{DEFAULT_CLUB_RANKS.map((item) => <option key={item.id} value={item.title} />)}</datalist>
<p className="text-xs text-outline mb-4">This title does not grant administrative permissions.</p>
</> : <p className="mb-4">This removes club access. The rider account and membership history will remain.</p>}
{actionError && <p role="alert" className="mb-4 text-error">{actionError}</p>}
<button className={`${communityButton} text-brand-cyan`} disabled={busy || (selection.action === 'rank' && !rank.trim())}>{busy ? 'Saving…' : selection.action === 'rank' ? 'Save rank' : 'Confirm removal'}</button>
</form>
</CommunityDialog>}
</section>
);
}

View File

@ -41,7 +41,7 @@ export function FounderSidebar({
const navItemsTop = [ const navItemsTop = [
{ id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false }, { id: 'overview', label: 'Overview', icon: 'dashboard', badge: 'ACTIVE', badgeType: 'primary', disabled: false },
{ id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false }, { id: 'profile', label: 'Club Profile', icon: 'shield', disabled: false },
{ id: 'members', label: 'Members', icon: 'group', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'members', label: 'Members', icon: 'group', disabled: false },
{ {
id: 'applications', id: 'applications',
label: 'Applications', label: 'Applications',
@ -63,7 +63,7 @@ export function FounderSidebar({
disabled: false, disabled: false,
}, },
{ id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'wall', label: 'Wall', icon: 'forum', disabled: false },
{ id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
{ id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'stats', label: 'Stats', icon: 'query_stats', badge: 'SOON', badgeType: 'disabled', disabled: true },
]; ];
@ -76,6 +76,8 @@ export function FounderSidebar({
function getRouteHref(id: string) { function getRouteHref(id: string) {
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`; if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'members') return `/clubs/${clubParam}/manage/members${queryParam}`;
if (id === 'wall') return `/clubs/${clubParam}/manage/wall${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`; if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`;
if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`; if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`;
@ -102,7 +104,7 @@ export function FounderSidebar({
const isSettings = item.id === 'settings'; const isSettings = item.id === 'settings';
const isApplications = item.id === 'applications'; const isApplications = item.id === 'applications';
const isRides = item.id === 'rides'; const isRides = item.id === 'rides';
const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles'; const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles' || item.id === 'members' || item.id === 'wall';
if (item.disabled) { if (item.disabled) {
return ( return (

View File

@ -0,0 +1,101 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import type { FounderClubContext } from '../../lib/club-data';
import { sanitizePortalError } from '../../lib/club-data';
import { createWallPost, deleteWallPost, replyToWallPost, setWallPostPinned, subscribeClubWall, type WallPost } from '../../lib/club-community';
import { CommunityDialog, communityButton, communityInput } from './community-dialog';
function postDate(value: string): string {
if (!value) return 'Sending…';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? 'Date unavailable' : date.toLocaleString();
}
export function FounderWall({ context }: { context: FounderClubContext }) {
const [posts, setPosts] = useState<WallPost[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [content, setContent] = useState('');
const [pinned, setPinned] = useState(false);
const [replies, setReplies] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const busyRef = useRef(false);
const [removing, setRemoving] = useState<WallPost | null>(null);
const [retry, setRetry] = useState(0);
const [notice, setNotice] = useState('');
useEffect(() => {
setLoading(true); setLoadError(null); setPosts([]);
if (context.isDemoFixture) { setLoading(false); return; }
return subscribeClubWall(context.club.id, (items) => { setPosts(items); setLoading(false); }, (err) => {
setPosts([]); setLoading(false); setLoadError(sanitizePortalError(err, 'The club wall could not be loaded.'));
});
}, [context, retry]);
async function perform(key: string, action: () => Promise<void>, after: () => void) {
if (busyRef.current || context.isDemoFixture) return;
busyRef.current = true; setBusy(key); setActionError(null); setNotice('');
try { await action(); after(); setNotice('Changes saved.'); }
catch (err) { setActionError(sanitizePortalError(err, 'Your changes could not be saved. Please try again.')); }
finally { busyRef.current = false; setBusy(null); }
}
const disabled = context.isDemoFixture || !!busy || loading || !!loadError;
return (
<section className="space-y-6 max-w-5xl">
<div><p className="font-mono text-xs text-brand-cyan mb-2">CLUB MANAGEMENT / COMMUNITY</p>
<h1 className="font-headline text-3xl font-bold text-white">Club Wall</h1>
<p className="mt-2 text-on-surface-variant">Leadership announcements and conversations with your club.</p></div>
{context.isDemoFixture && <p className="text-brand-cyan">Development preview • changes are disabled.</p>}
<form className="rounded-2xl border border-brand-border bg-brand-card p-5 space-y-4" onSubmit={(event) => {
event.preventDefault();
void perform('compose', () => createWallPost(context.club.id, content, pinned), () => { setContent(''); setPinned(false); });
}}>
<label className="block font-semibold text-white">Post to your club
<textarea id="wall-post-content" value={content} maxLength={5000} required rows={4} onChange={(event) => setContent(event.target.value)}
className={`${communityInput} mt-3 resize-y`} placeholder="Share an announcement, ride briefing, or discussion…" /></label>
<div className="flex flex-wrap items-center justify-between gap-4">
<label className="flex gap-2 items-center text-on-surface-variant"><input type="checkbox" checked={pinned} onChange={(event) => setPinned(event.target.checked)} />Pin leadership announcement</label>
<button disabled={disabled || !content.trim()} className={`${communityButton} bg-primary-container text-on-primary-container font-semibold`}>{busy === 'compose' ? 'Publishing…' : 'Publish post'}</button>
</div>
</form>
{actionError && <p role="alert" className="rounded-xl border border-error/30 p-4 text-error">{actionError}</p>}
<p role="status" className="text-secondary">{notice}</p>
{loading ? <p role="status">Loading club wall…</p> : loadError ? <div className="space-y-3"><p role="alert" className="text-error">{loadError}</p><button className={communityButton} onClick={() => setRetry((value) => value + 1)}>Try again</button></div>
: posts.length === 0 ? <div className="rounded-2xl border border-dashed border-brand-border p-10 text-center"><span aria-hidden="true" className="material-symbols-outlined text-4xl text-brand-cyan">forum</span><h2 className="text-xl font-headline text-white mt-3">Start the conversation</h2><p className="text-on-surface-variant mt-2">No club posts yet. Share your first update above.</p></div>
: <div className="space-y-5">{posts.map((post) => <article key={post.id} aria-label={`Post by ${post.authorDisplayName}`} className={`rounded-2xl border bg-brand-card p-5 sm:p-6 ${post.isPinned ? 'border-brand-cyan/50' : 'border-brand-border'}`}>
{post.isPinned && <p className="font-mono text-xs text-brand-cyan mb-4">PINNED ANNOUNCEMENT</p>}
<header className="flex flex-wrap justify-between gap-3">
<div><p className="font-semibold text-white">{post.authorDisplayName} <span className="text-xs text-brand-cyan font-normal">{post.authorDisplayRank}</span></p>
<p className="text-xs text-outline mt-1">{post.authorHandle} · {postDate(post.createdAt)}</p></div>
<div className="flex gap-2">
<button className={communityButton} disabled={disabled} aria-label={`${post.isPinned ? 'Unpin' : 'Pin'} post by ${post.authorDisplayName}`} onClick={() => void perform(post.id, () => setWallPostPinned(context.club.id, post.id, !post.isPinned), () => {})}>{post.isPinned ? 'Unpin' : 'Pin'}</button>
<button className={`${communityButton} text-error`} disabled={disabled} aria-label={`Delete post by ${post.authorDisplayName}`} onClick={() => setRemoving(post)}>Delete</button>
</div>
</header>
<p className="my-5 whitespace-pre-wrap break-words leading-relaxed text-on-surface">{post.content}</p>
<p className="font-mono text-xs text-outline border-t border-brand-border pt-3">{post.comments.length} replies · {post.fistBumpCount} fist bumps</p>
{post.comments.length > 0 && <ol className="mt-4 space-y-3">{post.comments.map((comment, index) => <li key={`${comment.id}-${index}`} className="rounded-xl bg-brand-bg p-4 border-l-2 border-brand-border">
<p className="text-xs font-semibold text-white">{comment.authorDisplayName} <span className="font-normal text-brand-cyan">{comment.authorDisplayRank}</span></p>
<p className="mt-2 whitespace-pre-wrap break-words">{comment.content}</p><p className="mt-2 text-xs text-outline">{postDate(comment.createdAt)}</p>
</li>)}</ol>}
<form className="mt-4 flex flex-col sm:flex-row gap-3" onSubmit={(event) => {
event.preventDefault();
void perform(post.id, () => replyToWallPost(context.club.id, post.id, replies[post.id] || ''), () => setReplies((current) => ({ ...current, [post.id]: '' })));
}}>
<label className="flex-1"><span className="sr-only">Reply to {post.authorDisplayName}</span><textarea rows={2} required maxLength={2000} className={communityInput} placeholder="Write a reply…" value={replies[post.id] || ''} onChange={(event) => setReplies((current) => ({ ...current, [post.id]: event.target.value }))} /></label>
<button className={communityButton} disabled={disabled || !replies[post.id]?.trim() || post.comments.length >= 200}>Reply</button>
</form>
{post.comments.length >= 200 && <p className="text-xs text-outline mt-2">This thread has reached its reply limit.</p>}
</article>)}</div>}
<p className="text-xs text-outline">Showing the 50 most recent posts, with pinned announcements first.</p>
{removing && <CommunityDialog title="Delete wall post" busy={!!busy} onClose={() => setRemoving(null)}>
<p className="mb-4">Delete this post and its replies? This cannot be undone.</p>
<button className={`${communityButton} text-error`} disabled={!!busy} onClick={() => void perform(removing.id, () => deleteWallPost(context.club.id, removing.id), () => setRemoving(null))}>Confirm deletion</button>
{actionError && <p role="alert" className="mt-3 text-error">{actionError}</p>}
</CommunityDialog>}
</section>
);
}

View File

@ -0,0 +1,179 @@
import {
collection, deleteDoc, doc, getDoc, limit, onSnapshot, orderBy, query,
runTransaction, serverTimestamp, setDoc, updateDoc,
type DocumentData, type Transaction,
} from 'firebase/firestore';
import { firebaseAuth, firestore } from './firebase-client';
import { fetchClubMembers } from './club-data';
import type { ClubMember, ClubRole } from '../components/founder-portal/types';
export interface WallComment {
id: string;
postId: string;
authorUid: string;
authorDisplayName: string;
authorHandle?: string;
authorDisplayRank?: string;
authorRole: ClubRole;
content: string;
createdAt: string;
}
export interface WallPost extends Omit<WallComment, 'postId'> {
clubId: string;
isPinned: boolean;
fistBumpCount: number;
comments: WallComment[];
}
function callerUid(): string {
const user = firebaseAuth.currentUser;
if (!user || user.isAnonymous) throw new Error('unauthenticated');
return user.uid;
}
// Only point-read privacy-filtered public profiles. Never read private user docs.
export async function fetchMemberRoster(clubId: string, afterUid?: string): Promise<ClubMember[]> {
const members = await fetchClubMembers(clubId, callerUid(), afterUid ? { afterUid } : undefined);
const hydrated: ClubMember[] = [];
for (let i = 0; i < members.length; i += 10) {
hydrated.push(...await Promise.all(members.slice(i, i + 10).map(async (member) => {
try {
const profile = await getDoc(doc(firestore, 'public_profiles', member.uid));
if (profile.exists()) {
const data = profile.data();
return { ...member, displayName: data.displayName || null,
handle: data.username ? `@${String(data.username).replace(/^@/, '')}` : null };
}
} catch { /* Restricted or absent profiles retain a conservative identity. */ }
return { ...member, displayName: null, handle: null };
})));
}
return hydrated;
}
async function managementContext(tx: Transaction, clubId: string, uid: string) {
const clubRef = doc(firestore, 'clubs', clubId);
const club = await tx.get(clubRef);
const member = await tx.get(doc(firestore, 'clubs', clubId, 'members', uid));
if (!club.exists() || club.data().archivedAt) throw new Error('not-found');
const isOwner = club.data().ownerUid === uid;
if (!isOwner && (!member.exists() || member.data().status !== 'active' || member.data().role !== 'admin')) {
throw new Error('permission-denied');
}
return { clubRef, data: club.data(), isOwner };
}
export async function removeClubMember(clubId: string, targetUid: string): Promise<void> {
const uid = callerUid();
if (targetUid === uid) throw new Error('Use the mobile leave-club flow to leave your club.');
await runTransaction(firestore, async (tx) => {
const context = await managementContext(tx, clubId, uid);
const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid);
const target = await tx.get(targetRef);
if (!target.exists() || target.data().status !== 'active') throw new Error('Member is no longer active.');
if (targetUid === context.data.ownerUid || target.data().role === 'owner' ||
(!context.isOwner && target.data().role === 'admin')) throw new Error('permission-denied');
if (typeof context.data.memberCount !== 'number' || context.data.memberCount <= 1) {
throw new Error('Club member count needs to be reconciled before removal.');
}
tx.update(targetRef, { status: 'removed', removedAt: serverTimestamp(), updatedAt: serverTimestamp() });
tx.update(context.clubRef, { memberCount: context.data.memberCount - 1,
lastMembershipChangeUid: targetUid, updatedAt: serverTimestamp() });
});
}
function isoDate(value: unknown): string {
if (value && typeof value === 'object' && 'toDate' in value && typeof value.toDate === 'function') {
return value.toDate().toISOString();
}
return typeof value === 'string' ? value : '';
}
function mapComment(data: DocumentData): WallComment {
return { id: String(data.id || ''), postId: String(data.postId || ''),
authorUid: String(data.authorUid || ''), authorDisplayName: String(data.authorDisplayName || 'Rider'),
authorHandle: data.authorHandle, authorDisplayRank: data.authorDisplayRank,
authorRole: data.authorRole === 'owner' || data.authorRole === 'admin' ? data.authorRole : 'member',
content: String(data.content || ''), createdAt: isoDate(data.createdAt) };
}
export function subscribeClubWall(
clubId: string, onPosts: (posts: WallPost[]) => void, onError: (error: unknown) => void,
): () => void {
return onSnapshot(query(collection(firestore, 'clubs', clubId, 'wall_posts'),
orderBy('createdAt', 'desc'), limit(50)), (snapshot) => {
const posts = snapshot.docs.map((item) => {
const data = item.data();
const { postId: _postId, ...author } = mapComment(data);
return { ...author, id: item.id, clubId, isPinned: data.isPinned === true,
fistBumpCount: typeof data.fistBumpCount === 'number' ? data.fistBumpCount : 0,
comments: Array.isArray(data.comments) ? data.comments.map(mapComment) : [] };
});
onPosts(posts.sort((a, b) => Number(b.isPinned) - Number(a.isPinned)));
}, onError);
}
function cleanContent(content: string, max: number): string {
const clean = content.trim();
if (!clean || clean.length > max) throw new Error(`Content must contain 1–${max} characters.`);
return clean;
}
async function wallAuthor(clubId: string) {
const uid = callerUid();
const [club, membership] = await Promise.all([
getDoc(doc(firestore, 'clubs', clubId)),
getDoc(doc(firestore, 'clubs', clubId, 'members', uid)),
]);
if (!club.exists() || club.data().archivedAt) throw new Error('not-found');
const owner = club.data().ownerUid === uid;
if (!owner && (!membership.exists() || membership.data().status !== 'active')) throw new Error('permission-denied');
const data = membership.exists() ? membership.data() : {};
const role: ClubRole = owner ? 'owner' : data.role === 'admin' ? 'admin' : 'member';
const author: Omit<WallComment, 'id' | 'postId' | 'content' | 'createdAt'> = {
authorUid: uid, authorDisplayName: 'Rider', authorRole: role,
};
if (data.displayRank) author.authorDisplayRank = data.displayRank;
const profile = await getDoc(doc(firestore, 'public_profiles', uid));
if (profile.exists()) {
author.authorDisplayName = profile.data().displayName || 'Rider';
if (profile.data().username) author.authorHandle = `@${String(profile.data().username).replace(/^@/, '')}`;
}
return author;
}
export async function createWallPost(clubId: string, content: string, isPinned: boolean): Promise<void> {
const clean = cleanContent(content, 5000);
const author = await wallAuthor(clubId);
if (isPinned && author.authorRole === 'member') throw new Error('permission-denied');
const ref = doc(collection(firestore, 'clubs', clubId, 'wall_posts'));
await setDoc(ref, { ...author, id: ref.id, clubId, content: clean,
createdAt: serverTimestamp(), isPinned, fistBumpCount: 0, fistBumpUserIds: [], comments: [] });
}
export async function replyToWallPost(clubId: string, postId: string, content: string): Promise<void> {
const clean = cleanContent(content, 2000);
const author = await wallAuthor(clubId);
const comment: WallComment = { ...author, id: crypto.randomUUID(), postId,
content: clean, createdAt: new Date().toISOString() };
const ref = doc(firestore, 'clubs', clubId, 'wall_posts', postId);
await runTransaction(firestore, async (tx) => {
const post = await tx.get(ref);
if (!post.exists()) throw new Error('not-found');
const comments = post.data().comments;
if (!Array.isArray(comments) || comments.length >= 200) throw new Error('This thread has reached its reply limit.');
// Retry against the latest list so simultaneous replies never overwrite each other.
tx.update(ref, { comments: [...comments, comment] });
});
}
export async function setWallPostPinned(clubId: string, postId: string, isPinned: boolean): Promise<void> {
callerUid();
await updateDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId), { isPinned });
}
export async function deleteWallPost(clubId: string, postId: string): Promise<void> {
callerUid();
await deleteDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId));
}

View File

@ -1,5 +1,5 @@
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction, orderBy } from 'firebase/firestore'; import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction, orderBy, documentId, startAfter } from 'firebase/firestore';
import { firestore } from './firebase-client'; import { firebaseAuth, firestore } from './firebase-client';
import { import {
Club, Club,
ClubMember, ClubMember,
@ -1475,15 +1475,15 @@ export const DEFAULT_RANK_PERMISSIONS: Record<string, Record<import('../componen
}; };
/** /**
* Fetches all active club members with their current roles and display ranks. * Fetches a page of active club members with their current roles and display ranks.
*/ */
export async function fetchClubMembers( export async function fetchClubMembers(
clubId: string, clubId: string,
callerUid?: string, callerUid?: string,
options?: { allowDemoPreview?: boolean } options?: { allowDemoPreview?: boolean; afterUid?: string }
): Promise<ClubMember[]> { ): Promise<ClubMember[]> {
const normalized = (clubId || '').trim().toLowerCase(); const normalized = (clubId || '').trim();
if (options?.allowDemoPreview && (process.env.NODE_ENV === 'development' || !callerUid)) { if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') {
return [ return [
{ {
uid: 'demo-founder-01', uid: 'demo-founder-01',
@ -1562,6 +1562,8 @@ export async function fetchClubMembers(
const q = query( const q = query(
membersRef, membersRef,
where('status', '==', 'active'), where('status', '==', 'active'),
orderBy(documentId()),
...(options?.afterUid ? [startAfter(options.afterUid)] : []),
limit(50) limit(50)
); );
@ -1591,17 +1593,31 @@ export async function updateMemberRank(
targetUid: string, targetUid: string,
newDisplayRank: string newDisplayRank: string
): Promise<void> { ): Promise<void> {
const normalized = (clubId || '').trim().toLowerCase(); const normalized = (clubId || '').trim();
const cleanRank = (newDisplayRank || '').trim(); const cleanRank = (newDisplayRank || '').trim();
if (!normalized || !targetUid) { if (!normalized || !targetUid) {
throw new Error('Club ID and target UID are required.'); throw new Error('Club ID and target UID are required.');
} }
if (!cleanRank || cleanRank.length > 60) {
throw new Error('Display rank must contain 1–60 characters.');
}
const user = firebaseAuth.currentUser;
if (!user || user.isAnonymous) throw new Error('unauthenticated');
const clubRef = doc(firestore, 'clubs', normalized);
const memberRef = doc(firestore, 'clubs', normalized, 'members', targetUid); const memberRef = doc(firestore, 'clubs', normalized, 'members', targetUid);
await updateDoc(memberRef, { await runTransaction(firestore, async (tx) => {
displayRank: cleanRank, const club = await tx.get(clubRef);
updatedAt: serverTimestamp(), const caller = await tx.get(doc(firestore, 'clubs', normalized, 'members', user.uid));
const target = await tx.get(memberRef);
if (!club.exists() || club.data().archivedAt) throw new Error('not-found');
if (club.data().ownerUid !== user.uid &&
(!caller.exists() || caller.data().status !== 'active' || caller.data().role !== 'admin')) {
throw new Error('permission-denied');
}
if (!target.exists() || target.data().status !== 'active') throw new Error('Member is no longer active.');
tx.update(memberRef, { displayRank: cleanRank, updatedAt: serverTimestamp() });
}); });
} }

View File

@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mock = vi.hoisted(() => ({
user: { uid: 'owner', isAnonymous: false } as { uid: string; isAnonymous: boolean } | null,
store: new Map<string, Record<string, unknown>>(), writes: vi.fn(), subscription: vi.fn(),
}));
vi.mock('../lib/firebase-client', () => ({ firestore: {}, firebaseAuth: { get currentUser() { return mock.user; } } }));
vi.mock('firebase/firestore', () => ({
doc: (_db: unknown, ...parts: string[]) => ({ path: parts.length ? parts.join('/') : 'clubs/ClubID/wall_posts/generated' }),
collection: (_db: unknown, ...parts: string[]) => ({ path: parts.join('/') }),
query: (...args: unknown[]) => args, where: (...args: unknown[]) => args,
limit: (value: number) => value, orderBy: (...args: unknown[]) => args, documentId: () => '__name__', startAfter: (value: string) => ({ afterUid: value }),
getDocs: vi.fn(async (q: unknown[]) => {
const after = q.find((item) => typeof item === 'object' && item !== null && 'afterUid' in item) as { afterUid: string } | undefined;
return { docs: Array.from(mock.store.entries())
.filter(([key, value]) => key.startsWith('clubs/ClubID/members/') && value.status === 'active' && (!after || key.split('/').pop()! > after.afterUid))
.sort(([a], [b]) => a < b ? -1 : 1).slice(0, 50)
.map(([key, value]) => ({ id: key.split('/').pop(), data: () => value })) };
}),
getDoc: vi.fn(async (ref: { path: string }) => ({ exists: () => mock.store.has(ref.path), data: () => mock.store.get(ref.path) })),
runTransaction: vi.fn(async (_db: unknown, callback: (tx: unknown) => unknown) => callback({
get: async (ref: { path: string }) => ({ exists: () => mock.store.has(ref.path), data: () => mock.store.get(ref.path) }),
update: (ref: { path: string }, data: unknown) => mock.writes(ref.path, data),
})),
updateDoc: async (ref: { path: string }, data: unknown) => mock.writes(ref.path, data),
setDoc: async (ref: { path: string }, data: unknown) => mock.writes(ref.path, data),
deleteDoc: async (ref: { path: string }) => mock.writes(ref.path, 'deleted'),
serverTimestamp: () => 'SERVER_TIMESTAMP',
onSnapshot: (...args: unknown[]) => mock.subscription(...args),
collectionGroup: vi.fn(),
}));
import { fetchClubMembers, updateMemberRank } from '../lib/club-data';
import { createWallPost, fetchMemberRoster, removeClubMember, replyToWallPost, subscribeClubWall } from '../lib/club-community';
import { getDoc, getDocs } from 'firebase/firestore';
beforeEach(() => {
vi.clearAllMocks();
mock.user = { uid: 'owner', isAnonymous: false };
mock.store.clear();
mock.store.set('clubs/ClubID', { ownerUid: 'owner', memberCount: 3, archivedAt: null });
mock.store.set('clubs/ClubID/members/owner', { role: 'owner', status: 'active', displayRank: 'President' });
mock.store.set('clubs/ClubID/members/admin', { role: 'admin', status: 'active', displayRank: 'Officer' });
mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'active', displayRank: 'Road Captain' });
mock.store.set('public_profiles/owner', { displayName: 'Morgan', username: 'morgan' });
mock.writes.mockReset();
});
afterEach(() => vi.unstubAllEnvs());
describe('Roster uses canonical club identity and privacy-filtered profiles', () => {
it('preserves case-sensitive club IDs and pages beyond 50 active members without overlap', async () => {
for (let i = 0; i < 60; i++) mock.store.set(`clubs/ClubID/members/rider${i}`, { role: 'member', status: 'active' });
const result = await fetchClubMembers('ClubID', 'owner');
expect(result).toHaveLength(50);
expect(result.every((item) => item.clubId === 'ClubID')).toBe(true);
expect(getDocs).toHaveBeenCalledWith([{ path: 'clubs/ClubID/members' }, ['status', '==', 'active'], ['__name__'], 50]);
const second = await fetchClubMembers('ClubID', 'owner', { afterUid: result[result.length - 1].uid });
expect(second).toHaveLength(13);
expect(new Set([...result, ...second].map((item) => item.uid)).size).toBe(63);
});
it('never returns demo members in production, including signed-out preview attempts', async () => {
vi.stubEnv('NODE_ENV', 'production');
const members = await fetchClubMembers('ClubID', undefined, { allowDemoPreview: true });
expect(members).toHaveLength(3);
expect(getDocs).toHaveBeenCalled();
});
it('hydrates only allowed public profiles, with no private account reads', async () => {
mock.store.set('public_profiles/admin', { displayName: 'Restricted profile' });
vi.mocked(getDoc).mockImplementationOnce(async () => { throw new Error('permission-denied'); });
const members = await fetchMemberRoster('ClubID');
expect(members.find((item) => item.uid === 'admin')?.displayName).toBe(null);
expect(members.find((item) => item.uid === 'owner')?.displayName).toBe('Morgan');
expect(vi.mocked(getDoc).mock.calls.every(([ref]) => (ref as unknown as { path: string }).path.startsWith('public_profiles/'))).toBe(true);
});
it('updates only cosmetic rank fields after checking current management access', async () => {
await updateMemberRank('ClubID', 'member', ' Tail Gunner ');
expect(mock.writes).toHaveBeenCalledExactlyOnceWith('clubs/ClubID/members/member', { displayRank: 'Tail Gunner', updatedAt: 'SERVER_TIMESTAMP' });
});
it.each(['', ' '.repeat(3), 'x'.repeat(61)])('rejects an invalid rank %j', async (rank) => {
await expect(updateMemberRank('ClubID', 'member', rank)).rejects.toThrow();
expect(mock.writes).not.toHaveBeenCalled();
});
it('rejects stale or non-admin membership before rank writes', async () => {
mock.user = { uid: 'member', isAnonymous: false };
await expect(updateMemberRank('ClubID', 'member', 'President')).rejects.toThrow('permission-denied');
expect(mock.writes).not.toHaveBeenCalled();
});
it('does not edit ranks on removed members', async () => {
mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'removed' });
await expect(updateMemberRank('ClubID', 'member', 'Member')).rejects.toThrow('no longer active');
expect(mock.writes).not.toHaveBeenCalled();
});
});
describe('Atomic club member removal', () => {
it('preserves the member document and binds the club count decrement to its UID', async () => {
await removeClubMember('ClubID', 'member');
expect(mock.writes.mock.calls).toEqual([
['clubs/ClubID/members/member', { status: 'removed', removedAt: 'SERVER_TIMESTAMP', updatedAt: 'SERVER_TIMESTAMP' }],
['clubs/ClubID', { memberCount: 2, lastMembershipChangeUid: 'member', updatedAt: 'SERVER_TIMESTAMP' }],
]);
});
it('owner may remove an admin', async () => {
await removeClubMember('ClubID', 'admin');
expect(mock.writes).toHaveBeenCalledTimes(2);
});
it('admin cannot remove a peer admin or owner', async () => {
mock.user = { uid: 'admin', isAnonymous: false };
mock.store.set('clubs/ClubID/members/peer', { role: 'admin', status: 'active' });
for (const uid of ['peer', 'owner']) await expect(removeClubMember('ClubID', uid)).rejects.toThrow('permission-denied');
expect(mock.writes).not.toHaveBeenCalled();
});
it('cannot remove self, or decrement again after removal', async () => {
await expect(removeClubMember('ClubID', 'owner')).rejects.toThrow('leave-club');
mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'removed' });
await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('no longer active');
expect(mock.writes).not.toHaveBeenCalled();
});
it('blocks inactive admins and archived clubs', async () => {
mock.user = { uid: 'admin', isAnonymous: false };
mock.store.set('clubs/ClubID/members/admin', { role: 'admin', status: 'removed' });
await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('permission-denied');
mock.user = { uid: 'owner', isAnonymous: false };
mock.store.set('clubs/ClubID', { ownerUid: 'owner', memberCount: 3, archivedAt: 'date' });
await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('not-found');
expect(mock.writes).not.toHaveBeenCalled();
});
});
describe('Wall persistence', () => {
it('uses current caller identity, role, public profile and server post timestamp', async () => {
await createWallPost('ClubID', ' Announcement ', true);
expect(mock.writes).toHaveBeenCalledWith('clubs/ClubID/wall_posts/generated', expect.objectContaining({
authorUid: 'owner', authorDisplayName: 'Morgan', authorRole: 'owner', authorDisplayRank: 'President',
content: 'Announcement', createdAt: 'SERVER_TIMESTAMP', isPinned: true, comments: [],
}));
});
it('propagates failed writes instead of returning a false success', async () => {
mock.writes.mockRejectedValueOnce(new Error('unavailable'));
await expect(createWallPost('ClubID', 'Announcement', true)).rejects.toThrow('unavailable');
});
it('preserves preceding mobile comments when appending a web reply', async () => {
const existing = { id: 'old', postId: 'post1', authorUid: 'member', content: 'Mobile reply', createdAt: '2026-01-01T00:00:00.000Z' };
mock.store.set('clubs/ClubID/wall_posts/post1', { comments: [existing] });
await replyToWallPost('ClubID', 'post1', ' Web reply ');
const comments = mock.writes.mock.calls[0][1].comments;
expect(comments).toHaveLength(2);
expect(comments[0]).toEqual(existing);
expect(comments[1]).toMatchObject({ postId: 'post1', authorUid: 'owner', content: 'Web reply' });
expect(typeof comments[1].createdAt).toBe('string');
});
it('sorts pinned posts first, handles empty snapshots, and returns listener cleanup', () => {
const cleanup = vi.fn();
mock.subscription.mockReturnValue(cleanup);
const onPosts = vi.fn(), onError = vi.fn();
expect(subscribeClubWall('ClubID', onPosts, onError)).toBe(cleanup);
const deliver = mock.subscription.mock.calls[0][1];
deliver({ docs: [{ id: 'recent', data: () => ({ ...postData(), content: 'newest' }) }, { id: 'pinned', data: () => ({ ...postData(), isPinned: true }) }] });
expect(onPosts.mock.calls[0][0].map((item: { id: string }) => item.id)).toEqual(['pinned', 'recent']);
deliver({ docs: [] });
expect(onPosts).toHaveBeenLastCalledWith([]);
});
it.each([null, { uid: 'owner', isAnonymous: true }])('denies missing or anonymous caller %j', async (user) => {
mock.user = user;
await expect(createWallPost('ClubID', 'Announcement', true)).rejects.toThrow('unauthenticated');
await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('unauthenticated');
expect(mock.writes).not.toHaveBeenCalled();
});
});
function postData() {
return { authorUid: 'member', authorDisplayName: 'Rider', authorRole: 'member', comments: [], createdAt: { toDate: () => new Date('2026-01-01') } };
}

View File

@ -0,0 +1,218 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FounderMembers } from '../components/founder-portal/founder-members';
import { FounderWall } from '../components/founder-portal/founder-wall';
import { FounderCommunityPage } from '../components/founder-portal/founder-community-page';
import { FounderSidebar } from '../components/founder-portal/founder-sidebar';
import type { FounderClubContext } from '../lib/club-data';
import type { WallPost } from '../lib/club-community';
const mocks = vi.hoisted(() => ({
fetchRoster: vi.fn(), remove: vi.fn(), rank: vi.fn(), subscribe: vi.fn(),
create: vi.fn(), reply: vi.fn(), pin: vi.fn(), delete: vi.fn(), context: vi.fn(), auth: vi.fn(), push: vi.fn(),
}));
vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} }));
vi.mock('firebase/auth', () => ({ onAuthStateChanged: (...args: unknown[]) => mocks.auth(...args) }));
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), useSearchParams: () => new URLSearchParams() }));
vi.mock('../components/founder-portal/founder-portal-shell', () => ({ FounderPortalShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
vi.mock('../lib/club-data', () => ({
DEFAULT_CLUB_RANKS: [{ id: 'captain', title: 'Road Captain' }], fetchClubMembers: (...args: unknown[]) => mocks.fetchRoster(...args),
updateMemberRank: (...args: unknown[]) => mocks.rank(...args),
fetchFounderClubContext: (...args: unknown[]) => mocks.context(...args),
sanitizePortalError: (_err: unknown, fallback = 'Club information could not be loaded.') => fallback,
}));
vi.mock('../lib/club-community', () => ({
fetchMemberRoster: (...args: unknown[]) => mocks.fetchRoster(...args), removeClubMember: (...args: unknown[]) => mocks.remove(...args),
subscribeClubWall: (...args: unknown[]) => mocks.subscribe(...args), createWallPost: (...args: unknown[]) => mocks.create(...args),
replyToWallPost: (...args: unknown[]) => mocks.reply(...args), setWallPostPinned: (...args: unknown[]) => mocks.pin(...args),
deleteWallPost: (...args: unknown[]) => mocks.delete(...args),
}));
const context: FounderClubContext = {
club: { id: 'ClubID', slug: 'club-slug', name: 'Test Club', ownerUid: 'owner', description: '', visibility: 'private', admissionPolicy: 'request', memberCount: 3, createdAt: '', updatedAt: '' },
currentUser: { uid: 'owner', role: 'owner' }, ownerProfile: null, isDemoFixture: false,
permissions: { isAuthenticated: true, role: 'owner', canManageSettings: true, canInviteMembers: true, canApproveApplications: true, canPlanRides: true, canTransferOwnership: true },
};
const members = [
{ uid: 'owner', clubId: 'ClubID', status: 'active', role: 'owner', displayName: 'Morgan', displayRank: 'President', updatedAt: '' },
{ uid: 'admin', clubId: 'ClubID', status: 'active', role: 'admin', displayName: 'Alex', displayRank: 'Vice President', updatedAt: '' },
{ uid: 'member', clubId: 'ClubID', status: 'active', role: 'member', displayName: 'Taylor', displayRank: 'Road Captain', updatedAt: '' },
];
const post: WallPost = { id: 'post1', clubId: 'ClubID', authorUid: 'member', authorDisplayName: 'Taylor', authorRole: 'member', content: 'Saturday briefing', createdAt: '2026-01-01T00:00:00Z', isPinned: true, fistBumpCount: 0, comments: [] };
let onPosts: (posts: WallPost[]) => void;
let onWallError: (err: unknown) => void;
let listenerCleanup: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); };
HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); };
mocks.fetchRoster.mockResolvedValue(members);
for (const operation of [mocks.remove, mocks.rank, mocks.create, mocks.reply, mocks.pin, mocks.delete]) operation.mockResolvedValue(undefined);
listenerCleanup = vi.fn();
mocks.subscribe.mockImplementation((_clubId, deliver, error) => { onPosts = deliver; onWallError = error; deliver([post]); return listenerCleanup; });
mocks.context.mockResolvedValue(context);
mocks.auth.mockImplementation((_auth, deliver) => { deliver({ uid: 'owner', isAnonymous: false }); return vi.fn(); });
});
afterEach(cleanup);
describe('Members management interactions', () => {
it('loads the next roster page by member UID and makes the search scope explicit', async () => {
const firstPage = Array.from({ length: 50 }, (_, i) => ({ ...members[2], uid: `rider-${i}`, displayName: `Rider ${i}` }));
mocks.fetchRoster.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([{ ...members[2], uid: 'last-rider', displayName: 'Last Rider' }]);
render(<FounderMembers context={context} />);
await screen.findByText('Rider 49');
expect(screen.getByText(/Search and counts cover loaded members/)).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: 'Load more members' }));
await screen.findByText('Last Rider');
expect(mocks.fetchRoster).toHaveBeenLastCalledWith('ClubID', 'rider-49');
expect(screen.queryByRole('button', { name: 'Load more members' })).toBeNull();
});
it('searches ranks and filters administrative roles independently', async () => {
render(<FounderMembers context={context} />);
await screen.findByText('Taylor');
fireEvent.change(screen.getByLabelText('Search roster'), { target: { value: 'captain' } });
expect(screen.queryByText('Alex')).toBeNull();
expect(screen.getByText('Taylor')).toBeDefined();
fireEvent.change(screen.getByLabelText('Administrative role'), { target: { value: 'admin' } });
expect(screen.getByText('No members match your search.')).toBeDefined();
});
it('opens a rank dialog, saves cosmetic rank and updates the roster', async () => {
render(<FounderMembers context={context} />);
await screen.findByText('Taylor');
fireEvent.click(screen.getByRole('button', { name: 'Edit rank for Taylor' }));
const dialog = screen.getByRole('dialog', { name: 'Edit display rank' });
fireEvent.change(within(dialog).getByLabelText('Display rank'), { target: { value: 'Tail Gunner' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Save rank' }));
await waitFor(() => expect(mocks.rank).toHaveBeenCalledWith('ClubID', 'member', 'Tail Gunner'));
await screen.findByText('Tail Gunner');
expect(screen.queryByRole('dialog')).toBeNull();
});
it('requires confirmation before removal and only removes after a successful write', async () => {
render(<FounderMembers context={context} />);
await screen.findByText('Taylor');
fireEvent.click(screen.getByRole('button', { name: 'Remove Taylor' }));
expect(mocks.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Confirm removal' }));
await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith('ClubID', 'member'));
await waitFor(() => expect(screen.queryByText('Taylor')).toBeNull());
});
it('keeps failed removal reviewable in the dialog, without hiding the rider', async () => {
mocks.remove.mockRejectedValueOnce(new Error('permission-denied'));
render(<FounderMembers context={context} />);
await screen.findByText('Taylor');
fireEvent.click(screen.getByRole('button', { name: 'Remove Taylor' }));
fireEvent.click(screen.getByRole('button', { name: 'Confirm removal' }));
await screen.findByRole('alert');
expect(screen.getByRole('dialog')).toBeDefined();
expect(screen.getAllByText('Taylor')).toHaveLength(2);
});
it('hides owner/self removal and peer-admin removal for admins', async () => {
render(<FounderMembers context={{ ...context, currentUser: { uid: 'admin', role: 'admin' } }} />);
await screen.findByText('Taylor');
expect(screen.queryByRole('button', { name: 'Remove Morgan' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove Alex' })).toBeNull();
expect(screen.getByRole('button', { name: 'Remove Taylor' })).toBeDefined();
});
it('shows a load failure rather than a false empty roster and permits retry', async () => {
mocks.fetchRoster.mockRejectedValueOnce(new Error('unavailable'));
render(<FounderMembers context={context} />);
await screen.findByRole('alert');
expect(screen.queryByText('There are no active members to display.')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Refresh roster' }));
await screen.findByText('Taylor');
});
});
describe('Wall interactions', () => {
it('publishes a pinned announcement and clears draft after persistence', async () => {
render(<FounderWall context={context} />);
fireEvent.change(screen.getByLabelText('Post to your club'), { target: { value: 'Friday briefing' } });
fireEvent.click(screen.getByLabelText('Pin leadership announcement'));
fireEvent.click(screen.getByRole('button', { name: 'Publish post' }));
await waitFor(() => expect(mocks.create).toHaveBeenCalledWith('ClubID', 'Friday briefing', true));
await waitFor(() => expect((screen.getByLabelText('Post to your club') as HTMLTextAreaElement).value).toBe(''));
});
it('retains drafts and displays a safe error when publishing fails', async () => {
mocks.create.mockRejectedValueOnce(new Error('secret-internal-path'));
render(<FounderWall context={context} />);
fireEvent.change(screen.getByLabelText('Post to your club'), { target: { value: 'Keep this draft' } });
fireEvent.click(screen.getByRole('button', { name: 'Publish post' }));
await screen.findByRole('alert');
expect((screen.getByLabelText('Post to your club') as HTMLTextAreaElement).value).toBe('Keep this draft');
expect(screen.queryByText('secret-internal-path')).toBeNull();
});
it('submits a reply and unpins through the service', async () => {
render(<FounderWall context={context} />);
fireEvent.change(screen.getByLabelText('Reply to Taylor'), { target: { value: 'Confirmed' } });
fireEvent.click(screen.getByRole('button', { name: 'Reply' }));
await waitFor(() => expect(mocks.reply).toHaveBeenCalledWith('ClubID', 'post1', 'Confirmed'));
await waitFor(() => expect((screen.getByLabelText('Reply to Taylor') as HTMLTextAreaElement).value).toBe(''));
fireEvent.click(screen.getByRole('button', { name: 'Unpin post by Taylor' }));
await waitFor(() => expect(mocks.pin).toHaveBeenCalledWith('ClubID', 'post1', false));
});
it('requires confirmation before deleting a post and replies', async () => {
render(<FounderWall context={context} />);
fireEvent.click(screen.getByRole('button', { name: 'Delete post by Taylor' }));
expect(mocks.delete).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => expect(mocks.delete).toHaveBeenCalledWith('ClubID', 'post1'));
});
it('shows permission failure instead of sample data and cleans up listeners', async () => {
mocks.subscribe.mockImplementation((_clubId, deliver, fail) => { fail(new Error('permission-denied')); return listenerCleanup; });
const view = render(<FounderWall context={context} />);
await screen.findByRole('alert');
expect(screen.queryByText('Start the conversation')).toBeNull();
expect((screen.getByRole('button', { name: 'Publish post' }) as HTMLButtonElement).disabled).toBe(true);
view.unmount();
expect(listenerCleanup).toHaveBeenCalledOnce();
});
it('never queries or writes to Firestore in an explicit development fixture', () => {
render(<FounderWall context={{ ...context, isDemoFixture: true }} />);
expect(mocks.subscribe).not.toHaveBeenCalled();
expect((screen.getByRole('button', { name: 'Publish post' }) as HTMLButtonElement).disabled).toBe(true);
});
});
describe('Direct Founder Portal route authorization', () => {
it('links both enabled sidebar modules to the club management routes', () => {
render(<FounderSidebar club={context.club} permissions={context.permissions} activeNav="members" />);
expect(screen.getByRole('link', { name: /Members/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/members');
expect(screen.getByRole('link', { name: /Wall/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/wall');
});
it.each(['members', 'wall'] as const)('denies signed-out users before querying %s data', async (section) => {
mocks.auth.mockImplementation((_auth, deliver) => { deliver(null); return vi.fn(); });
render(<FounderCommunityPage clubId="slug" section={section} />);
await screen.findByText('Authentication Required');
expect(mocks.context).not.toHaveBeenCalled();
expect(mocks.fetchRoster).not.toHaveBeenCalled();
expect(mocks.subscribe).not.toHaveBeenCalled();
});
it.each(['members', 'wall'] as const)('denies ordinary members before loading %s data', async (section) => {
mocks.context.mockResolvedValue({ ...context, permissions: { ...context.permissions, canManageSettings: false } });
render(<FounderCommunityPage clubId="slug" section={section} />);
await screen.findByText('Management Access Restricted');
expect(mocks.fetchRoster).not.toHaveBeenCalled();
expect(mocks.subscribe).not.toHaveBeenCalled();
});
it('resolves slug context before querying the canonical case-sensitive club ID', async () => {
render(<FounderCommunityPage clubId="slug" section="members" />);
await screen.findByText('Taylor');
expect(mocks.context).toHaveBeenCalledWith('slug', 'owner', { allowDemoPreview: false });
expect(mocks.fetchRoster).toHaveBeenCalledWith('ClubID');
});
it('clears the management page on sign-out and discards an in-flight context response', async () => {
let authChanged: (user: { uid: string; isAnonymous: boolean } | null) => void = () => {};
let resolveContext: (value: FounderClubContext) => void = () => {};
mocks.auth.mockImplementation((_auth, deliver) => { authChanged = deliver; deliver({ uid: 'owner', isAnonymous: false }); return vi.fn(); });
mocks.context.mockReturnValue(new Promise((resolve) => { resolveContext = resolve; }));
render(<FounderCommunityPage clubId="slug" section="members" />);
const { act } = await import('@testing-library/react');
await act(async () => { authChanged(null); });
await screen.findByText('Authentication Required');
await act(async () => { resolveContext(context); });
expect(screen.getByText('Authentication Required')).toBeDefined();
expect(mocks.fetchRoster).not.toHaveBeenCalled();
});
});