180 lines
8.2 KiB
TypeScript
180 lines
8.2 KiB
TypeScript
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));
|
||
}
|