feat: add club members and wall management
This commit is contained in:
parent
d378db4c86
commit
f495ec0cac
|
|
@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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" />;
|
||||||
|
}
|
||||||
|
|
@ -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" />;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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') } };
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue