Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions frontend/app/api/pools/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* /api/pools/messages — Pool group chat API
*
* GET /api/pools/messages?pool_id=<id>&wallet=<addr>&cursor=<iso>
* Returns the 50 most recent messages, optionally before `cursor` for
* infinite-scroll pagination. Verifies the requesting wallet is a member.
*
* POST /api/pools/messages { pool_id, wallet_address, message }
* Inserts a new message. Enforces member check, length cap, and a
* per-sender 3-second DB-backed rate limit so it works correctly across
* all serverless instances (no shared in-memory state).
*/

import { getAdminClient } from "@/lib/supabase-admin"
import { NextRequest, NextResponse } from "next/server"
import { readLimiter } from "@/lib/rate-limit"
import { CHAT_MESSAGE_MAX_LENGTH, CHAT_RATE_LIMIT_MS } from "@/lib/constants"

const PAGE_SIZE = 50

// ── Helpers ───────────────────────────────────────────────────────────────────

async function isMember(poolId: string, wallet: string): Promise<boolean> {
const { data } = await getAdminClient()
.from("pool_members")
.select("id")
.eq("pool_id", poolId)
.eq("member_address", wallet.toLowerCase())
.maybeSingle()
return data !== null
}

/**
* DB-backed rate limit: fetch the sender's most recent message timestamp for
* this pool. Works correctly across all serverless instances because it reads
* from the shared database rather than a module-scoped Map.
*
* Returns the number of milliseconds the caller must still wait, or 0 if they
* are allowed to send now.
*/
async function getRateLimitWaitMs(poolId: string, wallet: string): Promise<number> {
const { data } = await getAdminClient()
.from("pool_messages")
.select("created_at")
.eq("pool_id", poolId)
.eq("sender_address", wallet.toLowerCase())
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle()

if (!data) return 0

const lastMs = new Date(data.created_at).getTime()
const elapsed = Date.now() - lastMs
return elapsed < CHAT_RATE_LIMIT_MS ? CHAT_RATE_LIMIT_MS - elapsed : 0
}

// ── GET ───────────────────────────────────────────────────────────────────────

export async function GET(req: NextRequest) {
const limited = readLimiter(req)
if (limited) return limited

const { searchParams } = req.nextUrl
const poolId = searchParams.get("pool_id")
const wallet = searchParams.get("wallet")?.toLowerCase()
const cursor = searchParams.get("cursor") // ISO timestamp — load messages before this

if (!poolId) return NextResponse.json({ error: "pool_id required" }, { status: 400 })
if (!wallet) return NextResponse.json({ error: "wallet required" }, { status: 400 })

// Verify membership before returning any messages (mirrors the RLS policy).
const member = await isMember(poolId, wallet)
if (!member) {
return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 })
}

let query = getAdminClient()
.from("pool_messages")
.select("id, pool_id, sender_address, message, created_at")
.eq("pool_id", poolId)
.order("created_at", { ascending: false })
.limit(PAGE_SIZE)

if (cursor) {
query = query.lt("created_at", cursor)
}

const { data, error } = await query

if (error) return NextResponse.json({ error: error.message }, { status: 500 })

// Return in chronological order so the UI can simply append.
const messages = (data ?? []).reverse()
return NextResponse.json({ messages, hasMore: (data ?? []).length === PAGE_SIZE })
}

// ── POST ──────────────────────────────────────────────────────────────────────

export async function POST(req: NextRequest) {
let body: { pool_id?: string; wallet_address?: string; message?: string }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}

const { pool_id, wallet_address, message } = body
const wallet = wallet_address?.toLowerCase()

if (!pool_id) return NextResponse.json({ error: "pool_id required" }, { status: 400 })
if (!wallet) return NextResponse.json({ error: "wallet_address required" }, { status: 400 })
if (!message || message.trim().length === 0) {
return NextResponse.json({ error: "message required" }, { status: 400 })
}

// Length cap
if (message.length > CHAT_MESSAGE_MAX_LENGTH) {
return NextResponse.json(
{ error: `Message exceeds ${CHAT_MESSAGE_MAX_LENGTH} character limit` },
{ status: 422 }
)
}

// DB-backed per-sender rate limit — safe across all serverless instances
const waitMs = await getRateLimitWaitMs(pool_id, wallet)
if (waitMs > 0) {
return NextResponse.json(
{
error: "TOO_MANY_REQUESTS",
message: `Please wait ${Math.ceil(waitMs / 1000)} second(s) before sending another message.`,
retryAfterMs: waitMs,
},
{
status: 429,
headers: { "Retry-After": String(Math.ceil(waitMs / 1000)) },
}
)
}

// Verify membership (server-enforced, not just UI-gated)
const member = await isMember(pool_id, wallet)
if (!member) {
return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 })
}

const { data, error } = await getAdminClient()
.from("pool_messages")
.insert({
pool_id,
sender_address: wallet,
message: message.trim(),
})
.select("id, pool_id, sender_address, message, created_at")
.single()

if (error) return NextResponse.json({ error: error.message }, { status: 500 })

return NextResponse.json({ message: data }, { status: 201 })
}
11 changes: 10 additions & 1 deletion frontend/app/dashboard/group/[id]/GroupClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroupMembers } from "@/components/group/group-members"
import { GroupActivity } from "@/components/group/group-activity"
import { GroupActions } from "@/components/group/group-actions"
import { RotationalTimelineContainer } from "@/components/group/rotational-timeline-container"
import { PoolChat } from "@/components/group/pool-chat"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import Link from "next/link"
Expand All @@ -20,6 +21,7 @@ interface Pool {
type: "rotational" | "target" | "flexible"
contract_address: string
token_address: string
pool_members?: { member_address: string }[]
}

const isPendingAddress = (addr: string) => !addr || addr === "pending_deployment"
Expand Down Expand Up @@ -92,6 +94,12 @@ export default function GroupClient({ params }: { params: Promise<{ id: string }
? pool.contract_address
: pool.id

// Determine membership: check the pool_members list returned by /api/pools
const isMember =
!!address &&
(pool.pool_members?.some((m) => m.member_address.toLowerCase() === address.toLowerCase()) ??
false)

return (
<div className="min-h-screen bg-background">
<DashboardHeader />
Expand All @@ -104,13 +112,14 @@ export default function GroupClient({ params }: { params: Promise<{ id: string }
</Button>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Left column: details + timeline + activity ──────────────── */}
{/* ── Left column: details + timeline + activity + chat ───────── */}
<div className="lg:col-span-2 space-y-6">
<GroupDetails groupId={id} contractAddress={cacheKey} poolAdmin={poolAdmin} />
{pool.type === "rotational" && (
<RotationalTimelineContainer groupId={id} contractAddress={cacheKey} />
)}
<GroupActivity groupId={id} contractAddress={cacheKey} startLedger={0} />
<PoolChat poolId={id} isMember={isMember} />
</div>

{/* ── Right column: actions + members ──────────────────────────── */}
Expand Down
Loading
Loading