fix(forum): complete member profiles — counts, avatars, strip AI metadata

This commit is contained in:
2026-06-26 23:46:59 +00:00
parent 4acc068732
commit eb05918960
7 changed files with 133 additions and 81 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { attachAuthorUsernames, buildAuthorUsernameMap } from '@/lib/forum/authorUsernames';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@@ -30,7 +31,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
if (repliesError) throw repliesError; if (repliesError) throw repliesError;
return NextResponse.json({ thread, replies: replies || [] }); const usernameMap = await buildAuthorUsernameMap(supabase);
const [threadWithUsername] = attachAuthorUsernames([thread], usernameMap);
return NextResponse.json({
thread: threadWithUsername,
replies: attachAuthorUsernames(replies, usernameMap),
});
} catch (err: any) { } catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 }); return NextResponse.json({ error: err.message }, { status: 500 });
} }

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { ensureForumProfile } from '@/lib/forum/profile'; import { ensureForumProfile } from '@/lib/forum/profile';
import { attachAuthorUsernames, buildAuthorUsernameMap } from '@/lib/forum/authorUsernames';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
@@ -50,7 +51,13 @@ export async function GET(req: NextRequest) {
const { data, error, count } = await query; const { data, error, count } = await query;
if (error) throw error; if (error) throw error;
return NextResponse.json({ threads: data, total: count, page, limit }); const usernameMap = await buildAuthorUsernameMap(supabase);
return NextResponse.json({
threads: attachAuthorUsernames(data, usernameMap),
total: count,
page,
limit,
});
} catch (err: any) { } catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 }); return NextResponse.json({ error: err.message }, { status: 500 });
} }

View File

@@ -6,7 +6,7 @@ export const revalidate = 120;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb"; const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
function client() { function client() {
return createClient(SUPABASE_URL, SUPABASE_ANON, { return createClient(SUPABASE_URL, SUPABASE_ANON, {
@@ -16,24 +16,19 @@ function client() {
} }
export async function GET( export async function GET(
req: Request, _req: Request,
{ params }: { params: Promise<{ username: string }> }, { params }: { params: Promise<{ username: string }> },
) { ) {
const { username } = await params; const { username } = await params;
const sb = client(); const sb = client();
// 1) Profile — accept EITHER username OR display_name (URL-encoded).
// forum_threads/forum_replies store author_name = display_name,
// so the link in the UI uses the display name; we resolve it here.
// Multiple profiles can share a display_name (Michael Carter, Alex Rivera,
// etc.) — pick the earliest-created profile deterministically rather than
// erroring out with maybeSingle().
const decoded = decodeURIComponent(username); const decoded = decodeURIComponent(username);
let { data: profile } = await sb let { data: profile } = await sb
.from("forum_user_profiles") .from("forum_user_profiles")
.select("*") .select("*")
.eq("username", decoded) .eq("username", decoded)
.maybeSingle(); .maybeSingle();
if (!profile) { if (!profile) {
const r2 = await sb const r2 = await sb
.from("forum_user_profiles") .from("forum_user_profiles")
@@ -48,51 +43,79 @@ export async function GET(
return NextResponse.json({ error: "not found" }, { status: 404 }); return NextResponse.json({ error: "not found" }, { status: 404 });
} }
// 2) Their threads (use display_name match since virtual users live const displayName = profile.display_name as string;
// outside the auth.users / bb.user_profiles graph and the
// forum_threads.author_id is NULL for them). const [
const displayName = (profile as any).display_name as string; { count: threadCount },
const { data: threads } = await sb { count: replyCount },
{ data: threads },
{ data: replies },
] = await Promise.all([
sb
.from("forum_threads") .from("forum_threads")
.select("id,title,reply_count,view_count,vote_score,created_at,category_id,forum_categories(name,slug)") .select("*", { count: "exact", head: true })
.eq("author_name", displayName) .eq("author_name", displayName),
.order("created_at", { ascending: false }) sb
.limit(50);
// 3) Recent replies
const { data: replies } = await sb
.from("forum_replies") .from("forum_replies")
.select("id,thread_id,body,vote_score,created_at,forum_threads(title)") .select("*", { count: "exact", head: true })
.eq("author_name", displayName),
sb
.from("forum_threads")
.select(
"id,title,reply_count,view_count,vote_score,created_at,category_id,forum_categories(name,slug)",
)
.eq("author_name", displayName) .eq("author_name", displayName)
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(20); .limit(50),
sb
.from("forum_replies")
.select("id,thread_id,body,vote_score,created_at")
.eq("author_name", displayName)
.order("created_at", { ascending: false })
.limit(20),
]);
// 4) Computed counts const threadIds = [...new Set((replies || []).map((r) => r.thread_id).filter(Boolean))];
const threadCount = (threads || []).length; let titleByThread: Record<string, string> = {};
const replyCount = (replies || []).length; if (threadIds.length > 0) {
const { data: threadTitles } = await sb
.from("forum_threads")
.select("id,title")
.in("id", threadIds);
titleByThread = Object.fromEntries(
(threadTitles || []).map((t) => [t.id, t.title]),
);
}
const repliesWithTitles = (replies || []).map((r) => ({
...r,
forum_threads: { title: titleByThread[r.thread_id] || "(thread)" },
}));
const totalThreads = threadCount ?? 0;
const totalReplies = replyCount ?? 0;
return NextResponse.json({ return NextResponse.json({
profile: { profile: {
username: (profile as any).username, username: profile.username,
display_name: (profile as any).display_name, display_name: profile.display_name,
bio: (profile as any).bio || null, bio: profile.bio || null,
avatar_url: (profile as any).avatar_url || null, avatar_url: profile.avatar_url || null,
role_title: (profile as any).role_title || null, role_title: profile.role_title || null,
company: (profile as any).company || null, company: profile.company || null,
location_city: (profile as any).location_city || null, location_city: profile.location_city || null,
location_country: (profile as any).location_country || null, location_country: profile.location_country || null,
years_experience: (profile as any).years_experience || null, years_experience: profile.years_experience || null,
ai_persona_archetype: (profile as any).ai_persona_archetype || null, expertise_level: profile.expertise_level || null,
expertise_level: (profile as any).expertise_level || null, expertise_tags: profile.expertise_tags || [],
expertise_tags: (profile as any).expertise_tags || [], signature: profile.signature || null,
writing_voice: (profile as any).writing_voice || null, join_date: profile.join_date || null,
signature: (profile as any).signature || null, last_seen_at: profile.last_seen_at || null,
join_date: (profile as any).join_date || null, thread_count: totalThreads,
last_seen_at: (profile as any).last_seen_at || null, reply_count: totalReplies,
thread_count: threadCount, post_count: totalThreads + totalReplies,
reply_count: replyCount,
}, },
threads: threads || [], threads: threads || [],
replies: replies || [], replies: repliesWithTitles,
}); });
} }

View File

@@ -21,11 +21,17 @@ interface ForumCategory {
last_activity_at?: string | null; last_activity_at?: string | null;
} }
function profileHref(thread: { author_username?: string | null; author_name: string }): string {
const slug = thread.author_username || thread.author_name;
return `/forum/user/${encodeURIComponent(slug)}`;
}
interface ForumThread { interface ForumThread {
id: string; id: string;
title: string; title: string;
author_id?: string | null; author_id?: string | null;
author_name: string; author_name: string;
author_username?: string | null;
reply_count: number; reply_count: number;
view_count: number; view_count: number;
last_reply_at: string; last_reply_at: string;
@@ -175,7 +181,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
{thread.title} {thread.title}
</p> </p>
<p className="text-[#475569] font-body text-xs mt-0.5"> <p className="text-[#475569] font-body text-xs mt-0.5">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#475569] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link> <Link href={profileHref(thread)} className="text-[#475569] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></> <> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></>
)} )}
@@ -407,7 +413,7 @@ export default function ForumIndexPage() {
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
by{' '} by{' '}
<Link <Link
href={`/profile/${thread.author_id}`} href={profileHref(thread)}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
className="text-[#1D4ED8] hover:underline" className="text-[#1D4ED8] hover:underline"
> >
@@ -530,7 +536,7 @@ export default function ForumIndexPage() {
{thread.title} {thread.title}
</h3> </h3>
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link> by <Link href={profileHref(thread)} className="text-[#888] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></> <> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></>
)} )}

View File

@@ -26,11 +26,17 @@ function InThreadAd({ adIndex }: { adIndex: number }) {
); );
} }
function profileHref(author: { author_username?: string | null; author_name: string }): string {
const slug = author.author_username || author.author_name;
return `/forum/user/${encodeURIComponent(slug)}`;
}
interface ForumThread { interface ForumThread {
id: string; id: string;
title: string; title: string;
body: string; body: string;
author_name: string; author_name: string;
author_username?: string | null;
status: string; status: string;
reply_count: number; reply_count: number;
view_count: number; view_count: number;
@@ -45,7 +51,7 @@ interface ForumReply {
id: string; id: string;
body: string; body: string;
author_name: string; author_name: string;
is_ai_response: boolean; author_username?: string | null;
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
vote_score: number; vote_score: number;
@@ -322,7 +328,7 @@ export default function ForumThreadPage() {
<Avatar name={thread.author_name} /> <Avatar name={thread.author_name} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#1D4ED8] hover:underline">{thread.author_name}</Link> <Link href={profileHref(thread)} className="text-white font-body font-semibold text-sm hover:text-[#1D4ED8] hover:underline">{thread.author_name}</Link>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span> <span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
@@ -353,7 +359,7 @@ export default function ForumThreadPage() {
<Avatar name={reply.author_name} /> <Avatar name={reply.author_name} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<Link href={`/forum/user/${encodeURIComponent(reply.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#1D4ED8] hover:underline">{reply.author_name}</Link> <Link href={profileHref(reply)} className="text-white font-body font-semibold text-sm hover:text-[#1D4ED8] hover:underline">{reply.author_name}</Link>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">#{idx + 1}</span> <span className="text-[#555] font-body text-xs">#{idx + 1}</span>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>

View File

@@ -17,15 +17,14 @@ interface ForumUserProfile {
location_city: string | null; location_city: string | null;
location_country: string | null; location_country: string | null;
years_experience: number | null; years_experience: number | null;
ai_persona_archetype: string | null;
expertise_level: string | null; expertise_level: string | null;
expertise_tags: string[]; expertise_tags: string[];
writing_voice: string | null;
signature: string | null; signature: string | null;
join_date: string | null; join_date: string | null;
last_seen_at: string | null; last_seen_at: string | null;
thread_count: number; thread_count: number;
reply_count: number; reply_count: number;
post_count: number;
} }
interface UserThread { interface UserThread {
@@ -47,22 +46,6 @@ interface UserReply {
forum_threads?: { title: string }; forum_threads?: { title: string };
} }
const ARCHETYPE_LABELS: Record<string, string> = {
audio_engineer: "Audio Engineer",
motion_designer: "Motion Designer",
broadcast_engineer: "Broadcast Engineer",
colorist: "Colorist",
editor: "Editor",
vfx_artist: "VFX Artist",
camera_operator: "Camera Operator",
lighting_designer: "Lighting Designer",
post_supervisor: "Post Supervisor",
gear_specialist: "Gear Specialist",
livestream_producer: "Livestream Producer",
generalist: "Generalist",
video_editor: "Video Editor",
};
function fmtDate(iso: string | null): string { function fmtDate(iso: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
try { try {
@@ -134,7 +117,6 @@ export default function ForumUserPage() {
const { profile, threads, replies } = data; const { profile, threads, replies } = data;
const location = [profile.location_city, profile.location_country].filter(Boolean).join(", "); const location = [profile.location_city, profile.location_country].filter(Boolean).join(", ");
const archetypeLabel = profile.ai_persona_archetype ? (ARCHETYPE_LABELS[profile.ai_persona_archetype] || profile.ai_persona_archetype) : null;
return ( return (
<> <>
@@ -171,11 +153,6 @@ export default function ForumUserPage() {
<p className="font-body text-sm text-[#1D4ED8] mt-0.5">@{profile.username}</p> <p className="font-body text-sm text-[#1D4ED8] mt-0.5">@{profile.username}</p>
<div className="flex flex-wrap items-center gap-2 mt-3"> <div className="flex flex-wrap items-center gap-2 mt-3">
{archetypeLabel && (
<span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#10b981] bg-[#10b981]/10 border border-[#10b981]/40 px-2 py-1 rounded-sm">
{archetypeLabel}
</span>
)}
{profile.expertise_level && ( {profile.expertise_level && (
<span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#a78bfa] bg-[#a78bfa]/10 border border-[#a78bfa]/40 px-2 py-1 rounded-sm"> <span className="font-body text-[10px] font-bold uppercase tracking-wider text-[#a78bfa] bg-[#a78bfa]/10 border border-[#a78bfa]/40 px-2 py-1 rounded-sm">
{profile.expertise_level} {profile.expertise_level}
@@ -271,6 +248,7 @@ export default function ForumUserPage() {
<dl className="text-sm space-y-2"> <dl className="text-sm space-y-2">
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Role</dt><dd className="text-[#0F172A]">{profile.role_title || "—"}</dd></div> <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Role</dt><dd className="text-[#0F172A]">{profile.role_title || "—"}</dd></div>
{profile.company && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Company</dt><dd className="text-[#0F172A]">{profile.company}</dd></div>} {profile.company && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Company</dt><dd className="text-[#0F172A]">{profile.company}</dd></div>}
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Posts</dt><dd className="text-[#0F172A]">{profile.post_count}</dd></div>
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Threads</dt><dd className="text-[#0F172A]">{profile.thread_count}</dd></div> <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Threads</dt><dd className="text-[#0F172A]">{profile.thread_count}</dd></div>
<div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Replies</dt><dd className="text-[#0F172A]">{profile.reply_count}</dd></div> <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Replies</dt><dd className="text-[#0F172A]">{profile.reply_count}</dd></div>
{profile.years_experience && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Years in industry</dt><dd className="text-[#0F172A]">{profile.years_experience}</dd></div>} {profile.years_experience && <div><dt className="text-[#666] text-[11px] uppercase tracking-wider">Years in industry</dt><dd className="text-[#0F172A]">{profile.years_experience}</dd></div>}

View File

@@ -0,0 +1,25 @@
import type { SupabaseClient } from "@supabase/supabase-js";
export async function buildAuthorUsernameMap(
supabase: SupabaseClient,
): Promise<Record<string, string>> {
const { data } = await supabase
.from("forum_user_profiles")
.select("username, display_name");
const map: Record<string, string> = {};
for (const row of data || []) {
if (row.display_name && row.username) {
map[row.display_name] = row.username;
}
}
return map;
}
export function attachAuthorUsernames<
T extends { author_name?: string | null },
>(rows: T[] | null | undefined, map: Record<string, string>) {
return (rows || []).map((row) => ({
...row,
author_username: row.author_name ? map[row.author_name] || null : null,
}));
}