|
| 1 | +import { bad, handleError, ok, safeJson, enforceRateLimit, BadRequestError } from "../../_utils"; |
| 2 | +import { createClient, isSupabaseConfigured } from "@/lib/supabase/server"; |
| 3 | +import { getAppUser } from "@/lib/auth"; |
| 4 | + |
| 5 | +export const runtime = "nodejs"; |
| 6 | +export const dynamic = "force-dynamic"; |
| 7 | + |
| 8 | +const KINDS = new Set(["opportunity", "project", "meet"]); |
| 9 | + |
| 10 | +/** Strip control characters; the club is plain text only (no HTML/markdown). */ |
| 11 | +function cleanText(v: unknown, max: number): string { |
| 12 | + if (typeof v !== "string") return ""; |
| 13 | + // eslint-disable-next-line no-control-regex |
| 14 | + return v.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); |
| 15 | +} |
| 16 | + |
| 17 | +/** List club posts (public — guests can browse before signing up). */ |
| 18 | +export async function GET(req: Request) { |
| 19 | + try { |
| 20 | + const limited = await enforceRateLimit(req, "default"); |
| 21 | + if (limited) return limited; |
| 22 | + if (!isSupabaseConfigured()) return ok({ configured: false, posts: [] }); |
| 23 | + |
| 24 | + const u = new URL(req.url); |
| 25 | + const kind = u.searchParams.get("kind") || ""; |
| 26 | + const supabase = createClient(); |
| 27 | + let query = supabase |
| 28 | + .from("club_posts") |
| 29 | + .select("id, author_name, kind, title, description, specialty, share_url, contact, status, created_at") |
| 30 | + .order("created_at", { ascending: false }) |
| 31 | + .limit(50); |
| 32 | + if (kind && KINDS.has(kind)) query = query.eq("kind", kind); |
| 33 | + const { data, error } = await query; |
| 34 | + if (error) throw error; |
| 35 | + |
| 36 | + // Tell the signed-in caller which posts are theirs / already joined. |
| 37 | + const user = await getAppUser(); |
| 38 | + let joinedIds: string[] = []; |
| 39 | + if (user && data?.length) { |
| 40 | + const { data: joins } = await supabase |
| 41 | + .from("club_joins") |
| 42 | + .select("post_id") |
| 43 | + .eq("user_id", user.id); |
| 44 | + joinedIds = (joins || []).map((j) => j.post_id as string); |
| 45 | + } |
| 46 | + return ok({ |
| 47 | + configured: true, |
| 48 | + signedIn: Boolean(user), |
| 49 | + posts: (data || []).map((p) => ({ ...p, joined: joinedIds.includes(p.id as string) })), |
| 50 | + }); |
| 51 | + } catch (e) { |
| 52 | + return handleError(e); |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +/** Create a post (signed-in users only). */ |
| 57 | +export async function POST(req: Request) { |
| 58 | + try { |
| 59 | + const limited = await enforceRateLimit(req, "verify"); // strict: 12/min/IP |
| 60 | + if (limited) return limited; |
| 61 | + if (!isSupabaseConfigured()) return bad("Community features need Supabase configured", 503); |
| 62 | + const user = await getAppUser(); |
| 63 | + if (!user) return bad("Sign in to post in the Publication Club", 401); |
| 64 | + |
| 65 | + const body = await safeJson<Record<string, unknown>>(req, "default"); |
| 66 | + const kind = cleanText(body.kind, 20); |
| 67 | + if (!KINDS.has(kind)) throw new BadRequestError("kind must be opportunity | project | meet"); |
| 68 | + const title = cleanText(body.title, 160); |
| 69 | + if (title.length < 8) throw new BadRequestError("title must be at least 8 characters"); |
| 70 | + const description = cleanText(body.description, 2000); |
| 71 | + if (description.length < 20) throw new BadRequestError("description must be at least 20 characters"); |
| 72 | + const specialty = cleanText(body.specialty, 80); |
| 73 | + const contact = cleanText(body.contact, 200); |
| 74 | + const shareUrl = cleanText(body.shareUrl, 300); |
| 75 | + // Only allow share links that point back to this platform — the club must |
| 76 | + // not become a link-drop for arbitrary external URLs. |
| 77 | + if (shareUrl && !/^https:\/\/[\w.-]+\/(\?share=|.*[?&]share=)/.test(shareUrl) && !shareUrl.startsWith("/?share=")) { |
| 78 | + throw new BadRequestError("shareUrl must be a MedCore share link (…?share=TOKEN)"); |
| 79 | + } |
| 80 | + |
| 81 | + const authorName = cleanText(body.authorName, 60) || user.email.split("@")[0]; |
| 82 | + |
| 83 | + const supabase = createClient(); |
| 84 | + const { data, error } = await supabase |
| 85 | + .from("club_posts") |
| 86 | + .insert({ |
| 87 | + user_id: user.id, |
| 88 | + author_name: authorName, |
| 89 | + kind, |
| 90 | + title, |
| 91 | + description, |
| 92 | + specialty: specialty || null, |
| 93 | + contact: contact || null, |
| 94 | + share_url: shareUrl || null, |
| 95 | + }) |
| 96 | + .select("id, created_at") |
| 97 | + .single(); |
| 98 | + if (error) throw error; |
| 99 | + return ok({ created: true, id: data.id, createdAt: data.created_at }); |
| 100 | + } catch (e) { |
| 101 | + return handleError(e); |
| 102 | + } |
| 103 | +} |
0 commit comments