MotorSignal-App/website/lib/club-community.ts

180 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
}