Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chat Room client one-to-one almost done #23

Merged
merged 3 commits into from
Nov 9, 2023
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
2 changes: 2 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ index2.html
dockerbuild.sh
*.sh

./myenv

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
50 changes: 50 additions & 0 deletions backend/internal/chat/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -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})
}
16 changes: 8 additions & 8 deletions backend/internal/chat/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 50 additions & 44 deletions backend/internal/chat/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions backend/internal/chat/repository_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 11 additions & 12 deletions backend/internal/chat/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
1 change: 1 addition & 0 deletions backend/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...
}

Expand Down
12 changes: 12 additions & 0 deletions backend/scripts/query-analyze.txt
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions frontend/src/components/Layout/Layout.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.layout {
display: flex;
flex-direction: column;
height: 100vh; /* This ensures the container takes the full height of the viewport */
}
16 changes: 16 additions & 0 deletions frontend/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.layout}>
<Header />
{children}
</div>
);
};
Loading