fix(forum): complete member profiles — counts, avatars, strip AI metadata
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/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 }> }) {
|
||||
try {
|
||||
@@ -30,7 +31,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
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) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { ensureForumProfile } from '@/lib/forum/profile';
|
||||
import { attachAuthorUsernames, buildAuthorUsernameMap } from '@/lib/forum/authorUsernames';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
@@ -50,7 +51,13 @@ export async function GET(req: NextRequest) {
|
||||
const { data, error, count } = await query;
|
||||
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) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const revalidate = 120;
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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() {
|
||||
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||
@@ -16,24 +16,19 @@ function client() {
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ username: string }> },
|
||||
) {
|
||||
const { username } = await params;
|
||||
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);
|
||||
|
||||
let { data: profile } = await sb
|
||||
.from("forum_user_profiles")
|
||||
.select("*")
|
||||
.eq("username", decoded)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile) {
|
||||
const r2 = await sb
|
||||
.from("forum_user_profiles")
|
||||
@@ -48,51 +43,79 @@ export async function GET(
|
||||
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2) Their threads (use display_name match since virtual users live
|
||||
// outside the auth.users / bb.user_profiles graph and the
|
||||
// forum_threads.author_id is NULL for them).
|
||||
const displayName = (profile as any).display_name as string;
|
||||
const { data: threads } = await 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)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
const displayName = profile.display_name as string;
|
||||
|
||||
// 3) Recent replies
|
||||
const { data: replies } = await sb
|
||||
.from("forum_replies")
|
||||
.select("id,thread_id,body,vote_score,created_at,forum_threads(title)")
|
||||
.eq("author_name", displayName)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
const [
|
||||
{ count: threadCount },
|
||||
{ count: replyCount },
|
||||
{ data: threads },
|
||||
{ data: replies },
|
||||
] = await Promise.all([
|
||||
sb
|
||||
.from("forum_threads")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("author_name", displayName),
|
||||
sb
|
||||
.from("forum_replies")
|
||||
.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)
|
||||
.order("created_at", { ascending: false })
|
||||
.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 threadCount = (threads || []).length;
|
||||
const replyCount = (replies || []).length;
|
||||
const threadIds = [...new Set((replies || []).map((r) => r.thread_id).filter(Boolean))];
|
||||
let titleByThread: Record<string, string> = {};
|
||||
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({
|
||||
profile: {
|
||||
username: (profile as any).username,
|
||||
display_name: (profile as any).display_name,
|
||||
bio: (profile as any).bio || null,
|
||||
avatar_url: (profile as any).avatar_url || null,
|
||||
role_title: (profile as any).role_title || null,
|
||||
company: (profile as any).company || null,
|
||||
location_city: (profile as any).location_city || null,
|
||||
location_country: (profile as any).location_country || null,
|
||||
years_experience: (profile as any).years_experience || null,
|
||||
ai_persona_archetype: (profile as any).ai_persona_archetype || null,
|
||||
expertise_level: (profile as any).expertise_level || null,
|
||||
expertise_tags: (profile as any).expertise_tags || [],
|
||||
writing_voice: (profile as any).writing_voice || null,
|
||||
signature: (profile as any).signature || null,
|
||||
join_date: (profile as any).join_date || null,
|
||||
last_seen_at: (profile as any).last_seen_at || null,
|
||||
thread_count: threadCount,
|
||||
reply_count: replyCount,
|
||||
username: profile.username,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio || null,
|
||||
avatar_url: profile.avatar_url || null,
|
||||
role_title: profile.role_title || null,
|
||||
company: profile.company || null,
|
||||
location_city: profile.location_city || null,
|
||||
location_country: profile.location_country || null,
|
||||
years_experience: profile.years_experience || null,
|
||||
expertise_level: profile.expertise_level || null,
|
||||
expertise_tags: profile.expertise_tags || [],
|
||||
signature: profile.signature || null,
|
||||
join_date: profile.join_date || null,
|
||||
last_seen_at: profile.last_seen_at || null,
|
||||
thread_count: totalThreads,
|
||||
reply_count: totalReplies,
|
||||
post_count: totalThreads + totalReplies,
|
||||
},
|
||||
threads: threads || [],
|
||||
replies: replies || [],
|
||||
replies: repliesWithTitles,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,17 @@ interface ForumCategory {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
author_id?: string | null;
|
||||
author_name: string;
|
||||
author_username?: string | null;
|
||||
reply_count: number;
|
||||
view_count: number;
|
||||
last_reply_at: string;
|
||||
@@ -175,7 +181,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||
{thread.title}
|
||||
</p>
|
||||
<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 && (
|
||||
<> · <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">
|
||||
by{' '}
|
||||
<Link
|
||||
href={`/profile/${thread.author_id}`}
|
||||
href={profileHref(thread)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-[#1D4ED8] hover:underline"
|
||||
>
|
||||
@@ -530,7 +536,7 @@ export default function ForumIndexPage() {
|
||||
{thread.title}
|
||||
</h3>
|
||||
<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 && (
|
||||
<> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
author_name: string;
|
||||
author_username?: string | null;
|
||||
status: string;
|
||||
reply_count: number;
|
||||
view_count: number;
|
||||
@@ -45,7 +51,7 @@ interface ForumReply {
|
||||
id: string;
|
||||
body: string;
|
||||
author_name: string;
|
||||
is_ai_response: boolean;
|
||||
author_username?: string | null;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
vote_score: number;
|
||||
@@ -322,7 +328,7 @@ export default function ForumThreadPage() {
|
||||
<Avatar name={thread.author_name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">{timeAgo(thread.created_at)}</span>
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
@@ -353,7 +359,7 @@ export default function ForumThreadPage() {
|
||||
<Avatar name={reply.author_name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">#{idx + 1}</span>
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
|
||||
@@ -17,15 +17,14 @@ interface ForumUserProfile {
|
||||
location_city: string | null;
|
||||
location_country: string | null;
|
||||
years_experience: number | null;
|
||||
ai_persona_archetype: string | null;
|
||||
expertise_level: string | null;
|
||||
expertise_tags: string[];
|
||||
writing_voice: string | null;
|
||||
signature: string | null;
|
||||
join_date: string | null;
|
||||
last_seen_at: string | null;
|
||||
thread_count: number;
|
||||
reply_count: number;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
interface UserThread {
|
||||
@@ -47,22 +46,6 @@ interface UserReply {
|
||||
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 {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
@@ -134,7 +117,6 @@ export default function ForumUserPage() {
|
||||
|
||||
const { profile, threads, replies } = data;
|
||||
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 (
|
||||
<>
|
||||
@@ -171,11 +153,6 @@ export default function ForumUserPage() {
|
||||
<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">
|
||||
{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 && (
|
||||
<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}
|
||||
@@ -271,6 +248,7 @@ export default function ForumUserPage() {
|
||||
<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>
|
||||
{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">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>}
|
||||
|
||||
25
src/lib/forum/authorUsernames.ts
Normal file
25
src/lib/forum/authorUsernames.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user