diff --git a/backend/.gitignore b/backend/.gitignore index a2e9924..625eb5a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -36,6 +36,8 @@ index2.html dockerbuild.sh *.sh +./myenv + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/backend/internal/chat/handler.go b/backend/internal/chat/handler.go index 676c8f8..7220a93 100644 --- a/backend/internal/chat/handler.go +++ b/backend/internal/chat/handler.go @@ -6,6 +6,8 @@ import ( "fmt" "log" "net/http" + "strconv" + "time" "github.com/gin-gonic/gin" ) @@ -70,3 +72,51 @@ func (h *Handler) JoinChatRoom(c *gin.Context) { c.JSON(http.StatusOK, res) } + +func (h *Handler) GetChatMessages(c *gin.Context) { + // Extract ChatRoomID from the context + chatRoomID, err := common.ChatRoomIDValidator(c) + if err != nil { + log.Printf("Error occurred with chat room ID: %v", err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid chat room ID"}) + return + } + + // Extract the cursor and pageSize from query parameters + cursorStr := c.DefaultQuery("cursor", "") // Cursor is provided by the client + pageSize := c.DefaultQuery("pageSize", "50") + + // Parse pageSize to an integer + pageSizeInt, err := strconv.Atoi(pageSize) + if err != nil || pageSizeInt <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page size"}) + return + } + + // Parse the cursor string to a time.Time + var cursorTime *time.Time + if cursorStr != "" { + parsedCursor, err := time.Parse(time.RFC3339, cursorStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cursor"}) + return + } + cursorTime = &parsedCursor + } + + // Call the service function to get paginated messages + messages, err := h.Service.GetPaginatedMessages(c.Request.Context(), chatRoomID, cursorTime, pageSizeInt) + if err != nil { + log.Printf("Error fetching messages: %v", err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching messages"}) + return + } + + // Determine the next cursor + var nextCursor string + if len(messages) > 0 { + nextCursor = messages[len(messages)-1].CreatedAt.Format(time.RFC3339) + } + + c.JSON(http.StatusOK, gin.H{"messages": messages, "nextCursor": nextCursor}) +} diff --git a/backend/internal/chat/model.go b/backend/internal/chat/model.go index 6920b7a..b7167f7 100644 --- a/backend/internal/chat/model.go +++ b/backend/internal/chat/model.go @@ -12,14 +12,14 @@ type ChatRoom struct { } type Message struct { - ID int `json:"id"` - ChatRoomID uuid.UUID `json:"chat_room_id"` - SenderID uuid.UUID `json:"sender_id"` - Content string `json:"content"` - MediaURL string `json:"media_url"` - CreatedAt time.Time `json:"created_at"` - ReadAt time.Time `json:"read_at"` - DeletedByUserID uuid.UUID `json:"deleted_by_user_id"` + ID int `json:"id"` + ChatRoomID uuid.UUID `json:"chat_room_id"` + SenderID uuid.UUID `json:"sender_id"` + Content string `json:"content"` + MediaURL string `json:"media_url"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"read_at,omitempty"` + DeletedByUserID *uuid.UUID `json:"deleted_by_user_id,omitempty"` } type UserInChatRoom struct { diff --git a/backend/internal/chat/repository.go b/backend/internal/chat/repository.go index 0ffcd32..2db0e28 100644 --- a/backend/internal/chat/repository.go +++ b/backend/internal/chat/repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "log" "time" @@ -17,8 +18,7 @@ type Repository interface { FetchRecentMessages(ctx context.Context, chatRoomID uuid.UUID, limit int) ([]Message, error) JoinChatRoomByID(ctx context.Context, chatRoomID uuid.UUID, userID uuid.UUID) (*ChatRoom, error) SaveMessage(ctx context.Context, message *Message) error - //FetchMessagesFromRedis(ctx context.Context, chatRoomID uuid.UUID, limit int) ([]Message, error) - FetchMessagesFromDatabase(ctx context.Context, chatRoomID uuid.UUID, page int, limit int) ([]Message, error) + GetPaginatedMessages(ctx context.Context, chatRoomID uuid.UUID, cursor *time.Time, pageSize int) ([]Message, error) } type DBTX interface { @@ -172,65 +172,71 @@ func (r *repository) SaveMessage(ctx context.Context, message *Message) error { return nil } -/* -func (r *repository) FetchMessagesFromRedis(ctx context.Context, chatRoomID uuid.UUID, limit int) ([]Message, error) { - redisKey := "chatroom:" + chatRoomID.String() + ":messages" - messagesJSON, err := r.redisClient.LRange(ctx, redisKey, -limit, -1).Result() - if err != nil { - if err == redis.Nil { - // Redis key does not exist - return nil, nil - } - // Some other error occurred - return nil, err - } - - var messages []Message - for _, mJSON := range messagesJSON { - var msg Message - err := json.Unmarshal([]byte(mJSON), &msg) - if err != nil { - log.Printf("Failed to unmarshal message: %v", err) - // Decide how you want to handle partial failure - continue +// GetPaginatedMessages retrieves messages from the database with pagination. +func (r *repository) GetPaginatedMessages(ctx context.Context, chatRoomID uuid.UUID, cursor *time.Time, pageSize int) ([]Message, error) { + /* + offset := (pageNum - 1) * pageSize // calculate the offset + if offset < 0 { + offset = 0 // ensure offset is not negative } - messages = append(messages, msg) - } - return messages, nil -} */ - -// FetchMessagesFromDatabase retrieves chat messages from PostgreSQL -func (r *repository) FetchMessagesFromDatabase(ctx context.Context, chatRoomID uuid.UUID, page int, limit int) ([]Message, error) { - offset := page * limit + query := ` + SELECT id, chat_room_id, sender_id, content, media_url, created_at, read_at, deleted_by_user_id + FROM messages + WHERE chat_room_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` */ query := ` - SELECT id, chat_room_id, sender_id, content, created_at + SELECT id, chat_room_id, sender_id, content, media_url, created_at, read_at, deleted_by_user_id FROM messages - WHERE chat_room_id = $1 + WHERE chat_room_id = $1 AND created_at < $2 ORDER BY created_at DESC - LIMIT $2 OFFSET $3 + LIMIT $3 ` - rows, err := r.db.QueryContext(ctx, query, chatRoomID, limit, offset) + rows, err := r.db.QueryContext(ctx, query, chatRoomID, cursor, pageSize) if err != nil { - log.Printf("Failed to fetch messages from database: %v", err) - return nil, err + return nil, fmt.Errorf("querying for paginated messages: %w", err) } defer rows.Close() var messages []Message for rows.Next() { var msg Message - if err := rows.Scan(&msg.ID, &msg.ChatRoomID, &msg.SenderID, &msg.Content, &msg.CreatedAt); err != nil { - log.Printf("Failed to scan message: %v", err) - // Decide how you want to handle partial failure - continue + var readAt sql.NullTime + var deletedByUserID sql.NullString // Use sql.NullString for UUID fields that can be NULL + + if err := rows.Scan( + &msg.ID, + &msg.ChatRoomID, + &msg.SenderID, + &msg.Content, + &msg.MediaURL, + &msg.CreatedAt, + &readAt, + &deletedByUserID, + ); err != nil { + return nil, fmt.Errorf("scanning message: %w", err) + } + + // Check if readAt is valid, if so, assign it to the struct + if readAt.Valid { + msg.ReadAt = &readAt.Time } + + // Check if deletedByUserID is valid, if so, convert to uuid.UUID and assign it to the struct + if deletedByUserID.Valid { + uid, err := uuid.Parse(deletedByUserID.String) + if err != nil { + return nil, fmt.Errorf("parsing UUID: %w", err) + } + msg.DeletedByUserID = &uid + } + messages = append(messages, msg) } - - // Check for any error that occurred during iteration if err = rows.Err(); err != nil { - return nil, err + return nil, fmt.Errorf("iterating rows: %w", err) } return messages, nil diff --git a/backend/internal/chat/repository_test.go b/backend/internal/chat/repository_test.go new file mode 100644 index 0000000..9a7aabf --- /dev/null +++ b/backend/internal/chat/repository_test.go @@ -0,0 +1,40 @@ +package chat + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestCreateChatRoom(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("An error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRepository(db) + + chatRoom := &ChatRoom{} + expectedID := uuid.New() + + // Set up the expectations + mock.ExpectQuery("INSERT INTO chat_rooms"). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(expectedID)) + + // Call the method under test + result, err := repo.CreateChatRoom(context.Background(), chatRoom) + + // Assert the expectations + assert.NoError(t, err) + assert.Equal(t, expectedID, result.ID) + + // Ensure all expectations were met + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} diff --git a/backend/internal/chat/service.go b/backend/internal/chat/service.go index 608eb2b..bcf3e96 100644 --- a/backend/internal/chat/service.go +++ b/backend/internal/chat/service.go @@ -17,6 +17,7 @@ type Service interface { GetChatRoomInfoByID(ctx context.Context, chatRoomID uuid.UUID) (*ChatRoomInfo, error) JoinChatRoomByID(ctx context.Context, chatRoomID uuid.UUID, userID uuid.UUID) (*ChatRoom, error) RegisterClient(ctx context.Context, hub *Hub, conn *websocket.Conn, chatroomID uuid.UUID, userID uuid.UUID, kafkaProducer *confluentKafka.Producer) (*Client, error) + GetPaginatedMessages(ctx context.Context, chatRoomID uuid.UUID, cursorTime *time.Time, pageSize int) ([]Message, error) } type service struct { @@ -88,18 +89,6 @@ func (s *service) GetChatRoomInfoByID(ctx context.Context, chatRoomID uuid.UUID) return chatRoomInfo, nil } -/* -func (s *service) GetChatMessages(ctx context.Context, chatRoomID uuid.UUID, page int) ([]Message, error) { - // Attempt to fetch from Redis - messages, err := s.Repository.FetchMessagesFromRedis(ctx, chatRoomID, page) - if err == nil && len(messages) > 0 { - return messages, nil - } - - // Fallback to another method if Redis is empty or there's an error - return s.Repository.FetchMessagesFromDatabase(ctx, chatRoomID, page, 0) -} */ - func (s *service) RegisterClient(ctx context.Context, hub *Hub, conn *websocket.Conn, chatroomID uuid.UUID, userID uuid.UUID, kafkaProducer *confluentKafka.Producer) (*Client, error) { senderID := userID @@ -110,3 +99,13 @@ func (s *service) RegisterClient(ctx context.Context, hub *Hub, conn *websocket. client.hub.register <- client return client, nil } + +func (s *service) GetPaginatedMessages(ctx context.Context, chatRoomID uuid.UUID, cursorTime *time.Time, pageSize int) ([]Message, error) { + + res, err := s.Repository.GetPaginatedMessages(ctx, chatRoomID, cursorTime, pageSize) + if err != nil { + log.Printf("Error occured with getting paginated messages for chat room ID %v: %v", chatRoomID, err) + return nil, err + } + return res, nil +} diff --git a/backend/router/router.go b/backend/router/router.go index 5846894..fa309e3 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -77,6 +77,7 @@ func InitRouter(cfg *RouterConfig) { chatRoutes.POST("/", cfg.ChatHandler.CreateChatRoom) chatRoutes.GET("/:id", cfg.ChatHandler.GetChatRoom) chatRoutes.POST("/:id", cfg.ChatHandler.JoinChatRoom) + chatRoutes.GET("/:id/messages", cfg.ChatHandler.GetChatMessages) // etc... } diff --git a/backend/scripts/query-analyze.txt b/backend/scripts/query-analyze.txt new file mode 100644 index 0000000..8d3d74c --- /dev/null +++ b/backend/scripts/query-analyze.txt @@ -0,0 +1,12 @@ +EXPLAIN ANALYZE SELECT id, chat_room_id, sender_id, content, media_url, created_at, read_at, deleted_by_user_id + FROM messages + WHERE chat_room_id = '21e546be-629d-4e84-9236-b05c69becb18' + ORDER BY created_at DESC + LIMIT 50 OFFSET 0; + +EXPLAIN ANALYZE SELECT id, chat_room_id, sender_id, content, media_url, created_at, read_at, deleted_by_user_id + FROM messages + WHERE chat_room_id = '21e546be-629d-4e84-9236-b05c69becb18' + AND id < 12000 + ORDER BY id DESC + LIMIT 50; diff --git a/frontend/src/components/Layout/Layout.module.scss b/frontend/src/components/Layout/Layout.module.scss new file mode 100644 index 0000000..76e4ffe --- /dev/null +++ b/frontend/src/components/Layout/Layout.module.scss @@ -0,0 +1,5 @@ +.layout { + display: flex; + flex-direction: column; + height: 100vh; /* This ensures the container takes the full height of the viewport */ +} diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..106e749 --- /dev/null +++ b/frontend/src/components/Layout/Layout.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import styles from './Layout.module.scss'; +import { Header } from '@components/Header'; + +type LayoutProps = { + children: React.ReactNode; +}; + +export const Layout = ({ children }: LayoutProps) => { + return ( +
Loading...
; + if (status === 'error') returnError: {error.message}
; + + const supabaseData = localStorage.getItem(authTokenKey); + + let currentUserId = ''; + + if (supabaseData) { + const parsedData = JSON.parse(supabaseData); + currentUserId = parsedData.user.id; + console.log('Current user id', currentUserId); + } else { + console.error('No Supabase data found in Local Storage'); + } + const restAPIMessages: ServerMessageType[] = + data?.pages.flatMap((page) => + page.messages.map((message: unknown) => { + // Assert the message to be of type ServerMessageType + const serverMessage = message as unknown as ServerMessageType; + + const adaptedMessage: ServerMessageType = { + id: serverMessage.id, + chat_room_id: serverMessage.chat_room_id, + sender: + serverMessage.sender_id === currentUserId ? 'sent' : 'received', // Set the sender based on the current user's ID + media_url: serverMessage.media_url, + created_at: serverMessage.created_at, + content: serverMessage.content, + sender_id: serverMessage.sender_id, + }; + + return adaptedMessage; + }) + ) ?? []; + + const combinedMessages = [...storeMessages, ...propMessages].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); return ({text}
); diff --git a/frontend/src/features/chat/index.ts b/frontend/src/features/chat/index.ts index f102e3c..0f823dd 100644 --- a/frontend/src/features/chat/index.ts +++ b/frontend/src/features/chat/index.ts @@ -1,3 +1,5 @@ export * from './components/ChatArea'; export * from './components/InputArea'; export * from './components/Message'; +export * from './api/chatApi'; +export * from './types'; diff --git a/frontend/src/features/chat/types/index.ts b/frontend/src/features/chat/types/index.ts index 0cbb493..0aab125 100644 --- a/frontend/src/features/chat/types/index.ts +++ b/frontend/src/features/chat/types/index.ts @@ -1,5 +1,35 @@ export type MessageType = { sender: 'received' | 'sent'; - text: string; + senderID: string; + content: string; + createdAt: Date; img?: string; // Optional image URL for the sender's profile (for 'received' messages) }; + +// A type for the incoming message that reflects the server's response +export type ServerMessageType = { + id: number; + chat_room_id: string; + sender_id: string; + content: string; + media_url: string; + created_at: Date; + sender: 'received' | 'sent'; +}; + +export type CombinedMessageType = ChatMessage & ServerMessageType; + +// TypeScript interfaces to represent the API response structure +export interface ChatMessage { + id: number; + chatRoomId: string; + senderId: string; + content: string; + mediaUrl: string; + createdAt: string; // Use string if you plan to manually handle date parsing, otherwise consider using Date type +} + +export interface ChatMessagesResponse { + messages: ChatMessage[]; + nextCursor?: string; // Assuming your API provides a cursor for the next page +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..2c00986 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useWebSocketConnection'; +export * from './useChatMessages'; diff --git a/frontend/src/hooks/useChatMessages.tsx b/frontend/src/hooks/useChatMessages.tsx new file mode 100644 index 0000000..2ede0bd --- /dev/null +++ b/frontend/src/hooks/useChatMessages.tsx @@ -0,0 +1,12 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { ChatMessagesResponse, fetchChatMessagesFn } from '@features/chat'; + +export const useChatMessages = (chatRoomId: string) => { + return useInfiniteQuery