diff --git a/frontend/components/group/group-discussion.tsx b/frontend/components/group/group-discussion.tsx
new file mode 100644
index 0000000..d4a8576
--- /dev/null
+++ b/frontend/components/group/group-discussion.tsx
@@ -0,0 +1,367 @@
+"use client"
+
+import { useState, useEffect, useRef, useCallback } from "react"
+import { Card } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { AlertCircle, Send, MessageSquare, Loader2, Lock, Sparkles } from "lucide-react"
+import { getSupabaseClient } from "@/lib/supabase"
+import { useToast } from "@/hooks/use-toast"
+import { motion, AnimatePresence } from "framer-motion"
+
+interface GroupDiscussionProps {
+ groupId: string
+ walletAddress: string | null
+}
+
+interface Message {
+ id: string
+ pool_id: string
+ sender_address: string
+ message: string
+ created_at: string
+}
+
+export function GroupDiscussion({ groupId, walletAddress }: GroupDiscussionProps) {
+ const [messages, setMessages] = useState
([])
+ const [inputText, setInputText] = useState("")
+ const [isLoadingHistory, setIsLoadingHistory] = useState(false)
+ const [isSending, setIsSending] = useState(false)
+ const [error, setError] = useState(null)
+ const [rateLimitSeconds, setRateLimitSeconds] = useState(0)
+
+ const { toast } = useToast()
+ const scrollAreaRef = useRef(null)
+ const messagesEndRef = useRef(null)
+
+ // Scroll to bottom helper
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
+ setTimeout(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior })
+ }, 50)
+ }, [])
+
+ // Fetch initial chat history
+ const fetchMessages = useCallback(async () => {
+ if (!walletAddress) return
+ setIsLoadingHistory(true)
+ setError(null)
+
+ try {
+ const client = getSupabaseClient(walletAddress)
+ if (!client) throw new Error("Supabase client not initialized")
+
+ const { data, error: dbError } = await client
+ .from("pool_messages")
+ .select("*")
+ .eq("pool_id", groupId)
+ .order("created_at", { ascending: true })
+
+ if (dbError) {
+ console.error("Fetch messages error:", dbError)
+ setError("Failed to load chat history. Ensure you are an active member of this pool.")
+ } else {
+ setMessages(data || [])
+ scrollToBottom("auto")
+ }
+ } catch (err: any) {
+ console.error("Fetch messages catch:", err)
+ setError(err.message || "An unexpected error occurred.")
+ } finally {
+ setIsLoadingHistory(false)
+ }
+ }, [groupId, walletAddress, scrollToBottom])
+
+ // Setup Real-time listener and fetch history
+ useEffect(() => {
+ if (!walletAddress) return
+
+ fetchMessages()
+
+ const client = getSupabaseClient(walletAddress)
+ if (!client) return
+
+ const channel = client
+ .channel(`pool_messages:${groupId}`)
+ .on(
+ "postgres_changes",
+ {
+ event: "INSERT",
+ schema: "public",
+ table: "pool_messages",
+ filter: `pool_id=eq.${groupId}`,
+ },
+ (payload: any) => {
+ const newMessage = payload.new as Message
+ setMessages((prev) => {
+ // Avoid duplicate additions
+ if (prev.some((m) => m.id === newMessage.id)) return prev
+ return [...prev, newMessage]
+ })
+ scrollToBottom()
+ }
+ )
+ .subscribe()
+
+ return () => {
+ client.removeChannel(channel)
+ }
+ }, [groupId, walletAddress, fetchMessages, scrollToBottom])
+
+ // Decrement rate limit timer
+ useEffect(() => {
+ if (rateLimitSeconds <= 0) return
+ const timer = setTimeout(() => {
+ setRateLimitSeconds((prev) => prev - 1)
+ }, 1000)
+ return () => clearTimeout(timer)
+ }, [rateLimitSeconds])
+
+ // Format addresses for display
+ const formatAddress = (addr: string) => {
+ return `${addr.slice(0, 6)}...${addr.slice(-4)}`
+ }
+
+ // Handle message submission
+ const handleSendMessage = async (e?: React.FormEvent) => {
+ if (e) e.preventDefault()
+
+ const trimmed = inputText.trim()
+ if (!trimmed || !walletAddress) return
+ if (trimmed.length > 1000) {
+ toast({
+ title: "Message too long",
+ description: "Messages cannot exceed 1000 characters.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ if (rateLimitSeconds > 0) {
+ toast({
+ title: "Slow down",
+ description: `Please wait ${rateLimitSeconds} seconds before sending another message.`,
+ variant: "destructive",
+ })
+ return
+ }
+
+ setIsSending(true)
+ try {
+ const client = getSupabaseClient(walletAddress)
+ if (!client) throw new Error("Supabase client not initialized")
+
+ const { error: insertError } = await client
+ .from("pool_messages")
+ .insert([
+ {
+ pool_id: groupId,
+ sender_address: walletAddress.toLowerCase(),
+ message: trimmed,
+ },
+ ])
+
+ if (insertError) {
+ throw insertError
+ }
+
+ setInputText("")
+ setRateLimitSeconds(3) // 3 seconds rate limit
+ scrollToBottom()
+ } catch (err: any) {
+ console.error("Send message error:", err)
+ const errorMsg = err.message || ""
+
+ if (errorMsg.includes("Rate limit")) {
+ toast({
+ title: "Rate limit exceeded",
+ description: "Please wait 3 seconds between messages.",
+ variant: "destructive",
+ })
+ } else {
+ toast({
+ title: "Failed to send",
+ description: "Only active members of this pool can send messages.",
+ variant: "destructive",
+ })
+ }
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ // Handle keypress inside textarea (Enter to send, Shift+Enter for newline)
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ handleSendMessage()
+ }
+ }
+
+ if (!walletAddress) {
+ return (
+
+
+
+
+
+
Wallet Connection Required
+
+ Please connect your wallet at the top of the dashboard to view and participate in this pool's discussions.
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Discussion Header */}
+
+
+
+ Pool Discussion
+
+
+
+
+
+
+ Live Chat
+
+
+
+ {/* Message Area */}
+
+ {isLoadingHistory ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : error ? (
+
+
+
Access Restricted
+
+ {error}
+
+
+ ) : messages.length === 0 ? (
+
+
+
+
+
Welcome to the Pool!
+
+ This chat is secure and private to pool members. Start by saying hello to your fellow members!
+
+
+ ) : (
+
+
+ {messages.map((msg) => {
+ const isMe = msg.sender_address.toLowerCase() === walletAddress.toLowerCase()
+ return (
+
+
+
+ {msg.sender_address.slice(2, 4).toUpperCase()}
+
+
+
+
+
+
+ {formatAddress(msg.sender_address)}
+
+ {isMe && (
+
+ You
+
+ )}
+
+
+
+ {msg.message}
+
+
+
+ {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* Input Form */}
+ {!error && (
+
+ )}
+
+ )
+}
diff --git a/frontend/e2e/discussion.spec.ts b/frontend/e2e/discussion.spec.ts
new file mode 100644
index 0000000..c7c0705
--- /dev/null
+++ b/frontend/e2e/discussion.spec.ts
@@ -0,0 +1,133 @@
+import {
+ test,
+ expect,
+ connectWallet,
+ seedChainState,
+ mockPoolsApi,
+ makePool,
+ E2E_ADDRESS,
+ E2E_CONTRACT_ID,
+} from "./fixtures/test-base"
+
+test.describe("Pool Discussion / Chat", () => {
+ const POOL_ID = "chat-pool-id"
+
+ test.beforeEach(async ({ page }) => {
+ // 1. Connect wallet
+ await connectWallet(page)
+
+ // 2. Seed chain state so we are a member
+ await seedChainState(page, {
+ isActive: true,
+ admin: E2E_ADDRESS,
+ members: [E2E_ADDRESS],
+ })
+
+ // 3. Mock the pools API, showing us as a member in the DB
+ await mockPoolsApi(page, [
+ makePool({
+ id: POOL_ID,
+ name: "Chat Test Pool",
+ type: "rotational",
+ contract_address: E2E_CONTRACT_ID,
+ pool_members: [
+ {
+ id: "pm-1",
+ member_address: E2E_ADDRESS,
+ contribution_amount: 100,
+ status: "paid",
+ joined_at: new Date().toISOString(),
+ },
+ ],
+ }),
+ ])
+ })
+
+ test("can load, view and send messages, and enforces rate limit", async ({ page }) => {
+ const messages = [
+ {
+ id: "msg-1",
+ pool_id: POOL_ID,
+ sender_address: E2E_ADDRESS,
+ message: "Hello world!",
+ created_at: new Date(Date.now() - 60000).toISOString(),
+ },
+ ]
+
+ // Mock the Supabase REST GET for pool_messages
+ await page.route("**/rest/v1/pool_messages?**", async (route) => {
+ const method = route.request().method()
+ if (method === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(messages),
+ })
+ } else {
+ await route.continue()
+ }
+ })
+
+ // Mock the Supabase REST POST for inserting a message
+ await page.route("**/rest/v1/pool_messages", async (route) => {
+ const method = route.request().method()
+ if (method === "POST") {
+ const body = route.request().postDataJSON()
+ const newMsg = {
+ id: `msg-${Date.now()}`,
+ pool_id: POOL_ID,
+ sender_address: E2E_ADDRESS,
+ message: body[0].message,
+ created_at: new Date().toISOString(),
+ }
+ messages.push(newMsg)
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify([newMsg]),
+ })
+ } else {
+ await route.continue()
+ }
+ })
+
+ // Navigate to the pool details page
+ await page.goto(`/dashboard/group/${POOL_ID}`)
+
+ // Click the Discussion tab
+ await page.click("role=tab[name='Discussion']")
+
+ // Check that existing message is visible
+ await expect(page.locator("text=Hello world!")).toBeVisible()
+
+ // Type a new message
+ await page.fill("textarea[placeholder='Type a message...']", "This is an E2E test message!")
+
+ // Click send
+ await page.click("button:has(svg)")
+
+ // Verify it is visible in the chat feed
+ await expect(page.locator("text=This is an E2E test message!")).toBeVisible()
+
+ // Verify rate limit: textarea should have wait text or be disabled, showing warning label
+ await expect(page.locator("text=Rate limited: 3s remaining")).toBeVisible()
+ await expect(page.locator("textarea")).toBeDisabled()
+ })
+
+ test("restricted access shows restriction alert if user is not in pool", async ({ page }) => {
+ // Intercept with an error to simulate RLS access denied
+ await page.route("**/rest/v1/pool_messages?**", async (route) => {
+ await route.fulfill({
+ status: 403,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "permission denied" }),
+ })
+ })
+
+ await page.goto(`/dashboard/group/${POOL_ID}`)
+ await page.click("role=tab[name='Discussion']")
+
+ // Should show the restricted access card/alert
+ await expect(page.locator("text=Access Restricted")).toBeVisible()
+ })
+})
diff --git a/frontend/hooks/useNotifications.ts b/frontend/hooks/useNotifications.ts
index e6df3da..2e604d8 100644
--- a/frontend/hooks/useNotifications.ts
+++ b/frontend/hooks/useNotifications.ts
@@ -44,7 +44,7 @@ export function useNotifications(walletAddress: string | null) {
table: "notifications",
filter: `wallet_address=eq.${walletAddress.toLowerCase()}`,
},
- (payload) => {
+ (payload: any) => {
setNotifications((prev) =>
[payload.new as AppNotification, ...prev].slice(0, 10)
)
diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts
index b92d66c..b883b53 100644
--- a/frontend/lib/supabase.ts
+++ b/frontend/lib/supabase.ts
@@ -9,6 +9,19 @@ export const supabase = isValid(supabaseUrl)
? createClient(supabaseUrl, supabaseAnonKey)
: null as any
+export function getSupabaseClient(walletAddress?: string | null) {
+ if (!walletAddress || !isValid(supabaseUrl)) {
+ return supabase
+ }
+ return createClient(supabaseUrl, supabaseAnonKey, {
+ global: {
+ headers: {
+ 'x-wallet-address': walletAddress.toLowerCase(),
+ },
+ },
+ })
+}
+
export type Database = {
public: {
Tables: {
@@ -256,6 +269,29 @@ export type Database = {
read?: boolean
}
}
+ pool_messages: {
+ Row: {
+ id: string
+ pool_id: string
+ sender_address: string
+ message: string
+ created_at: string
+ }
+ Insert: {
+ id?: string
+ pool_id: string
+ sender_address: string
+ message: string
+ created_at?: string
+ }
+ Update: {
+ id?: string
+ pool_id?: string
+ sender_address?: string
+ message?: string
+ created_at?: string
+ }
+ }
}
}
}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 4676dd2..c1d7853 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -49,7 +49,7 @@ export default defineConfig({
expect: { timeout: 10_000 },
use: {
- baseURL: "http://localhost:3000",
+ baseURL: "http://127.0.0.1:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
@@ -64,7 +64,7 @@ export default defineConfig({
webServer: {
// Dev locally for fast iteration; build+start in CI for stability/speed.
command: isCI ? "pnpm build && pnpm start" : "pnpm dev",
- url: "http://localhost:3000",
+ url: "http://127.0.0.1:3000",
reuseExistingServer: !isCI,
timeout: 180_000,
env: E2E_ENV,
diff --git a/supabase/migrations/20260623000000_pool_messages.sql b/supabase/migrations/20260623000000_pool_messages.sql
new file mode 100644
index 0000000..477e4ea
--- /dev/null
+++ b/supabase/migrations/20260623000000_pool_messages.sql
@@ -0,0 +1,148 @@
+-- Migration: Real-time Pool Chat Messages
+-- Description: Adds pool_messages table with RLS policy, realtime enablement, and database-enforced rate limiting / validation.
+
+-- 1. Create pool_messages table
+CREATE TABLE IF NOT EXISTS pool_messages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ pool_id UUID NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
+ sender_address TEXT NOT NULL,
+ message TEXT NOT NULL CONSTRAINT chk_message_length CHECK (char_length(message) <= 1000),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Index for querying chat history quickly by pool_id and date
+CREATE INDEX IF NOT EXISTS idx_pool_messages_pool_id_created_at
+ ON pool_messages(pool_id, created_at ASC);
+
+-- 2. Create function to extract wallet address from request headers or JWT claims
+CREATE OR REPLACE FUNCTION get_request_wallet_address()
+RETURNS TEXT AS $$
+DECLARE
+ headers_text TEXT;
+ claims_text TEXT;
+ addr TEXT;
+BEGIN
+ -- Try to get from header first
+ BEGIN
+ headers_text := current_setting('request.headers', true);
+ IF headers_text IS NOT NULL AND headers_text <> '' THEN
+ addr := (headers_text::jsonb)->>'x-wallet-address';
+ IF addr IS NOT NULL THEN
+ RETURN lower(addr);
+ END IF;
+ END IF;
+ EXCEPTION WHEN OTHERS THEN
+ -- Ignore JSON parsing errors
+ END;
+
+ -- Try to get from JWT claims
+ BEGIN
+ claims_text := current_setting('request.jwt.claims', true);
+ IF claims_text IS NOT NULL AND claims_text <> '' THEN
+ addr := (claims_text::jsonb)->>'wallet_address';
+ IF addr IS NOT NULL THEN
+ RETURN lower(addr);
+ END IF;
+
+ addr := (claims_text::jsonb)->'user_metadata'->>'wallet_address';
+ IF addr IS NOT NULL THEN
+ RETURN lower(addr);
+ END IF;
+ END IF;
+ EXCEPTION WHEN OTHERS THEN
+ -- Ignore JSON parsing errors
+ END;
+
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- 3. Enable Row Level Security (RLS)
+ALTER TABLE pool_messages ENABLE ROW LEVEL SECURITY;
+
+-- 4. Create RLS Policies restricting access to pool members only
+CREATE POLICY "Allow pool members to read messages" ON pool_messages
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1 FROM pool_members
+ WHERE pool_members.pool_id = pool_messages.pool_id
+ AND LOWER(pool_members.member_address) = LOWER(get_request_wallet_address())
+ )
+ );
+
+CREATE POLICY "Allow pool members to insert messages" ON pool_messages
+ FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM pool_members
+ WHERE pool_members.pool_id = pool_messages.pool_id
+ AND LOWER(pool_members.member_address) = LOWER(get_request_wallet_address())
+ )
+ AND LOWER(sender_address) = LOWER(get_request_wallet_address())
+ );
+
+-- 5. Rate limiting logic: Max 1 message per 3 seconds per sender
+CREATE OR REPLACE FUNCTION enforce_message_rate_limit()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pool_messages
+ WHERE LOWER(sender_address) = LOWER(NEW.sender_address)
+ AND created_at >= NOW() - INTERVAL '3 seconds'
+ ) THEN
+ RAISE EXCEPTION 'Rate limit exceeded. Please wait 3 seconds between messages.';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER trigger_enforce_message_rate_limit
+BEFORE INSERT ON pool_messages
+FOR EACH ROW
+EXECUTE FUNCTION enforce_message_rate_limit();
+
+-- 6. Notification integration: Notify other pool members on message insert
+CREATE OR REPLACE FUNCTION notify_pool_message_inserted()
+RETURNS TRIGGER AS $$
+DECLARE
+ p_name TEXT;
+BEGIN
+ -- Get pool name
+ SELECT name INTO p_name FROM pools WHERE id = NEW.pool_id;
+
+ -- Insert a notification for all other members of this pool
+ INSERT INTO notifications (wallet_address, pool_id, activity_type, message)
+ SELECT
+ member_address,
+ NEW.pool_id,
+ 'new_message',
+ 'New message in ' || COALESCE(p_name, 'pool') || ': ' || substring(NEW.message from 1 for 50)
+ FROM pool_members
+ WHERE pool_id = NEW.pool_id
+ AND LOWER(member_address) <> LOWER(NEW.sender_address);
+
+ RETURN NEW;
+EXCEPTION
+ WHEN OTHERS THEN
+ -- Make sure failing to notify does not block message insertion
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+CREATE OR REPLACE TRIGGER trigger_notify_pool_message_inserted
+AFTER INSERT ON pool_messages
+FOR EACH ROW
+EXECUTE FUNCTION notify_pool_message_inserted();
+
+-- 7. Enable Realtime replication for pool_messages
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE pool_messages;
+ END IF;
+EXCEPTION
+ WHEN OTHERS THEN
+ -- Ignore errors if table is already in publication or publication does not exist
+END;
+$$;