diff --git a/backend/cmd/server.go b/backend/cmd/server.go
index 7b44657..7ef853b 100644
--- a/backend/cmd/server.go
+++ b/backend/cmd/server.go
@@ -10,6 +10,7 @@ import (
"github.com/egeuysall/cove/internal/api"
supabase "github.com/egeuysall/cove/internal/supabase"
generated "github.com/egeuysall/cove/internal/supabase/generated"
+ "github.com/egeuysall/cove/internal/websocket"
"github.com/joho/godotenv"
)
@@ -26,7 +27,12 @@ func main() {
utils.Init(queries)
- router := api.Router()
+ // Initialize WebSocket hub
+ hub := websocket.NewHub()
+ go hub.Run()
+ utils.SetHub(hub)
+
+ router := api.Router(hub)
portStr := os.Getenv("PORT")
diff --git a/backend/go.mod b/backend/go.mod
index c51587b..4c5802f 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -13,6 +13,7 @@ require (
require (
github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 8c61fa0..b09c196 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -11,6 +11,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index b804961..5279c71 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -5,12 +5,13 @@ import (
"github.com/egeuysall/cove/internal/handlers"
appmid "github.com/egeuysall/cove/internal/middleware"
+ "github.com/egeuysall/cove/internal/websocket"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
)
-func Router() *chi.Mux {
+func Router(hub *websocket.Hub) *chi.Mux {
r := chi.NewRouter()
// Global middleware
@@ -33,6 +34,10 @@ func Router() *chi.Mux {
r.Route("/v1", func(r chi.Router) {
r.Use(appmid.RequireAuth())
+ // WebSocket
+ wsHandler := websocket.NewHandler(hub)
+ r.Get("/ws", wsHandler.ServeWs)
+
// Groups
r.Post("/groups", handlers.HandleCreateGroup)
r.Get("/groups", handlers.HandleGetGroupsByUser)
diff --git a/backend/internal/handlers/links.go b/backend/internal/handlers/links.go
index a5eac2b..89a5745 100644
--- a/backend/internal/handlers/links.go
+++ b/backend/internal/handlers/links.go
@@ -85,7 +85,14 @@ func HandleCreateLink(w http.ResponseWriter, r *http.Request) {
return
}
- utils.SendJson(w, convertToResponse(link), http.StatusCreated)
+ linkResponse := convertToResponse(link)
+
+ // Broadcast to WebSocket clients
+ if utils.Hub != nil {
+ utils.Hub.BroadcastToGroup(req.GroupID, "link_created", linkResponse)
+ }
+
+ utils.SendJson(w, linkResponse, http.StatusCreated)
}
func HandleGetLinkById(w http.ResponseWriter, r *http.Request) {
@@ -263,7 +270,14 @@ func HandleUpdateLinkComment(w http.ResponseWriter, r *http.Request) {
return
}
- utils.SendJson(w, convertToResponse(updatedLink), http.StatusOK)
+ linkResponse := convertToResponse(updatedLink)
+
+ // Broadcast to WebSocket clients
+ if utils.Hub != nil {
+ utils.Hub.BroadcastToGroup(utils.UUIDToString(updatedLink.GroupID), "link_updated", linkResponse)
+ }
+
+ utils.SendJson(w, linkResponse, http.StatusOK)
}
func HandleDeleteLink(w http.ResponseWriter, r *http.Request) {
@@ -291,6 +305,22 @@ func HandleDeleteLink(w http.ResponseWriter, r *http.Request) {
return
}
+ // Get link before deleting to broadcast group ID
+ link, err := utils.Queries.GetLinkByID(r.Context(), linkId)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ utils.SendError(w, "Link not found", http.StatusNotFound)
+ return
+ }
+ utils.SendError(w, "Failed to get link", http.StatusInternalServerError)
+ return
+ }
+
+ if link.UserID != userId {
+ utils.SendError(w, "Not authorized to delete this link", http.StatusForbidden)
+ return
+ }
+
deleteParams := supabase.DeleteLinkParams{
ID: linkId,
UserID: userId,
@@ -302,6 +332,13 @@ func HandleDeleteLink(w http.ResponseWriter, r *http.Request) {
return
}
+ // Broadcast to WebSocket clients
+ if utils.Hub != nil {
+ utils.Hub.BroadcastToGroup(utils.UUIDToString(link.GroupID), "link_deleted", map[string]string{
+ "id": utils.UUIDToString(link.ID),
+ })
+ }
+
utils.SendJson(w, "Link deleted successfully", http.StatusOK)
}
diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go
index a01f07b..884cba2 100644
--- a/backend/internal/middleware/middleware.go
+++ b/backend/internal/middleware/middleware.go
@@ -37,18 +37,26 @@ func RequireAuth() func(http.Handler) http.Handler {
return
}
+ var tokenStr string
+
+ // Try to get token from Authorization header first
authHeader := r.Header.Get("Authorization")
- if authHeader == "" {
- utils.SendError(w, "Unauthorized: missing Authorization header", http.StatusUnauthorized)
- return
+ if authHeader != "" {
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
+ tokenStr = parts[1]
+ }
+ }
+
+ // If no token in header, try query parameter (for WebSocket connections)
+ if tokenStr == "" {
+ tokenStr = r.URL.Query().Get("token")
}
- parts := strings.SplitN(authHeader, " ", 2)
- if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
- utils.SendError(w, "Unauthorized: invalid Authorization header format", http.StatusUnauthorized)
+ if tokenStr == "" {
+ utils.SendError(w, "Unauthorized: missing token", http.StatusUnauthorized)
return
}
- tokenStr := parts[1]
// Parse and validate the token
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go
index 8d8432a..51fef9b 100644
--- a/backend/internal/models/models.go
+++ b/backend/internal/models/models.go
@@ -44,3 +44,14 @@ type LinkResponse struct {
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+
+// WebSocket message types
+type WebSocketMessage struct {
+ Type string `json:"type"`
+ Data interface{} `json:"data"`
+}
+
+type SubscribeMessage struct {
+ Type string `json:"type"`
+ GroupID string `json:"groupId"`
+}
diff --git a/backend/internal/utils/utils.go b/backend/internal/utils/utils.go
index 2c4fde6..7f4330a 100644
--- a/backend/internal/utils/utils.go
+++ b/backend/internal/utils/utils.go
@@ -3,6 +3,7 @@ package utils
import (
"encoding/json"
generated "github.com/egeuysall/cove/internal/supabase/generated"
+ "github.com/egeuysall/cove/internal/websocket"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"log"
@@ -10,11 +11,16 @@ import (
)
var Queries *generated.Queries
+var Hub *websocket.Hub
func Init(q *generated.Queries) {
Queries = q
}
+func SetHub(h *websocket.Hub) {
+ Hub = h
+}
+
func SendJson(w http.ResponseWriter, message interface{}, statusCode int) {
w.WriteHeader(statusCode)
diff --git a/backend/internal/websocket/handler.go b/backend/internal/websocket/handler.go
new file mode 100644
index 0000000..6c9da57
--- /dev/null
+++ b/backend/internal/websocket/handler.go
@@ -0,0 +1,65 @@
+package websocket
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/gorilla/websocket"
+)
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ // Allow connections from configured origins
+ origin := r.Header.Get("Origin")
+ return origin == "https://www.cove.egeuysal.com" ||
+ origin == "http://localhost:3000" ||
+ origin == "http://localhost:3001"
+ },
+}
+
+// Handler handles WebSocket upgrade requests
+type Handler struct {
+ Hub *Hub
+}
+
+// NewHandler creates a new WebSocket handler
+func NewHandler(hub *Hub) *Handler {
+ return &Handler{
+ Hub: hub,
+ }
+}
+
+// ServeWs handles WebSocket requests from clients
+func (h *Handler) ServeWs(w http.ResponseWriter, r *http.Request) {
+ // Get user ID from context (set by JWT middleware)
+ userID, ok := r.Context().Value("userID").(string)
+ if !ok || userID == "" {
+ log.Printf("No user ID in context")
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("Failed to upgrade connection: %v", err)
+ return
+ }
+
+ client := &Client{
+ Hub: h.Hub,
+ Conn: conn,
+ Send: make(chan []byte, 256),
+ UserID: userID,
+ GroupIDs: make(map[string]bool),
+ }
+
+ client.Hub.register <- client
+
+ // Start goroutines for reading and writing
+ go client.WritePump()
+ go client.ReadPump()
+
+ log.Printf("WebSocket connection established for user: %s", userID)
+}
diff --git a/backend/internal/websocket/hub.go b/backend/internal/websocket/hub.go
new file mode 100644
index 0000000..44dfb46
--- /dev/null
+++ b/backend/internal/websocket/hub.go
@@ -0,0 +1,187 @@
+package websocket
+
+import (
+ "encoding/json"
+ "log"
+ "sync"
+
+ "github.com/gorilla/websocket"
+)
+
+// Client represents a WebSocket client connection
+type Client struct {
+ Hub *Hub
+ Conn *websocket.Conn
+ Send chan []byte
+ UserID string
+ GroupIDs map[string]bool // Set of group IDs this client is subscribed to
+ mu sync.RWMutex
+}
+
+// Hub maintains active clients and broadcasts messages to them
+type Hub struct {
+ // Registered clients by user ID
+ clients map[string]*Client
+
+ // Register requests from clients
+ register chan *Client
+
+ // Unregister requests from clients
+ unregister chan *Client
+
+ // Broadcast message to all clients in a group
+ broadcast chan *BroadcastMessage
+
+ mu sync.RWMutex
+}
+
+// BroadcastMessage contains a message and the group ID to broadcast to
+type BroadcastMessage struct {
+ GroupID string
+ Message []byte
+}
+
+// NewHub creates a new Hub instance
+func NewHub() *Hub {
+ return &Hub{
+ clients: make(map[string]*Client),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ broadcast: make(chan *BroadcastMessage, 256),
+ }
+}
+
+// Run starts the hub's main loop
+func (h *Hub) Run() {
+ for {
+ select {
+ case client := <-h.register:
+ h.mu.Lock()
+ h.clients[client.UserID] = client
+ h.mu.Unlock()
+ log.Printf("Client registered: %s", client.UserID)
+
+ case client := <-h.unregister:
+ h.mu.Lock()
+ if _, ok := h.clients[client.UserID]; ok {
+ delete(h.clients, client.UserID)
+ close(client.Send)
+ log.Printf("Client unregistered: %s", client.UserID)
+ }
+ h.mu.Unlock()
+
+ case message := <-h.broadcast:
+ h.mu.RLock()
+ for _, client := range h.clients {
+ client.mu.RLock()
+ isSubscribed := client.GroupIDs[message.GroupID]
+ client.mu.RUnlock()
+
+ if isSubscribed {
+ select {
+ case client.Send <- message.Message:
+ default:
+ // Client's send channel is full, close the connection
+ h.mu.RUnlock()
+ h.mu.Lock()
+ close(client.Send)
+ delete(h.clients, client.UserID)
+ h.mu.Unlock()
+ h.mu.RLock()
+ }
+ }
+ }
+ h.mu.RUnlock()
+ }
+ }
+}
+
+// BroadcastToGroup sends a message to all clients subscribed to a group
+func (h *Hub) BroadcastToGroup(groupID string, messageType string, data interface{}) {
+ message := map[string]interface{}{
+ "type": messageType,
+ "data": data,
+ }
+
+ messageBytes, err := json.Marshal(message)
+ if err != nil {
+ log.Printf("Error marshaling broadcast message: %v", err)
+ return
+ }
+
+ h.broadcast <- &BroadcastMessage{
+ GroupID: groupID,
+ Message: messageBytes,
+ }
+}
+
+// SubscribeToGroup adds a group ID to the client's subscription list
+func (c *Client) SubscribeToGroup(groupID string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.GroupIDs[groupID] = true
+ log.Printf("Client %s subscribed to group %s", c.UserID, groupID)
+}
+
+// UnsubscribeFromGroup removes a group ID from the client's subscription list
+func (c *Client) UnsubscribeFromGroup(groupID string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.GroupIDs, groupID)
+ log.Printf("Client %s unsubscribed from group %s", c.UserID, groupID)
+}
+
+// ReadPump pumps messages from the WebSocket connection to the hub
+func (c *Client) ReadPump() {
+ defer func() {
+ c.Hub.unregister <- c
+ c.Conn.Close()
+ }()
+
+ for {
+ _, message, err := c.Conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+ log.Printf("WebSocket error: %v", err)
+ }
+ break
+ }
+
+ // Handle incoming messages (e.g., subscribe/unsubscribe)
+ var msg map[string]interface{}
+ if err := json.Unmarshal(message, &msg); err != nil {
+ log.Printf("Error unmarshaling message: %v", err)
+ continue
+ }
+
+ msgType, ok := msg["type"].(string)
+ if !ok {
+ continue
+ }
+
+ switch msgType {
+ case "subscribe":
+ if groupID, ok := msg["groupId"].(string); ok {
+ c.SubscribeToGroup(groupID)
+ }
+ case "unsubscribe":
+ if groupID, ok := msg["groupId"].(string); ok {
+ c.UnsubscribeFromGroup(groupID)
+ }
+ }
+ }
+}
+
+// WritePump pumps messages from the hub to the WebSocket connection
+func (c *Client) WritePump() {
+ defer func() {
+ c.Conn.Close()
+ }()
+
+ for message := range c.Send {
+ if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
+ log.Printf("Error writing message: %v", err)
+ return
+ }
+ }
+}
diff --git a/frontend/src/app/groups/[id]/page.tsx b/frontend/src/app/groups/[id]/page.tsx
index ff82312..39566ca 100644
--- a/frontend/src/app/groups/[id]/page.tsx
+++ b/frontend/src/app/groups/[id]/page.tsx
@@ -3,8 +3,8 @@ import {notFound} from "next/navigation";
import {cookies} from "next/headers";
import React from "react";
import {Group, Links} from "@/types/api";
-import {LinkCard} from "@/components/link-card";
import {CreateLink} from "@/components/create-link";
+import {RealTimeLinks} from "@/components/real-time-links";
const DynamicGroups = async ({ params }: { params: Promise<{ id: string}> }) => {
const { id } = await params;
@@ -85,23 +85,7 @@ const DynamicGroups = async ({ params }: { params: Promise<{ id: string}> }) =>
by {data?.user?.user_metadata?.name || data?.user?.user_metadata?.email}
- {links.length > 0 ? (
- links.slice().reverse().map((link: Links) => (
-