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
2 changes: 0 additions & 2 deletions server/controllers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}

// Generate the tokens
tokens, err := services.GenerateTokenPair(user.ID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
Expand All @@ -51,7 +50,6 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}

// FIX: Persist the registration refresh token to the database
tokenRepo := repository.NewRefreshTokenRepository()
hashedRt := services.HashToken(tokens.RefreshToken)
expiry := time.Now().Add(7 * 24 * time.Hour)
Expand Down
9 changes: 5 additions & 4 deletions server/controllers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (
)

type SendMessageReq struct {
ReceiverID string `json:"receiver_id"`
Content string `json:"content"`
ReceiverID string `json:"receiver_id"`
Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
}

func HandleSendMessage(w http.ResponseWriter, r *http.Request) {
Expand All @@ -41,7 +42,7 @@ func HandleSendMessage(w http.ResponseWriter, r *http.Request) {
}

svc := services.NewMessageService()
msg, err := svc.SendMessage(r.Context(), senderID, req.ReceiverID, req.Content)
msg, err := svc.SendMessage(r.Context(), senderID, req.ReceiverID, req.Content, req.ReplyToMessageID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()})
Expand All @@ -68,7 +69,7 @@ func HandleGetChatHistory(w http.ResponseWriter, r *http.Request) {

limit, _ := strconv.Atoi(limitStr)
if limit <= 0 || limit > 100 {
limit = 50 // Reference requirement ceiling limit mapping
limit = 50
}

before := time.Now()
Expand Down
1 change: 0 additions & 1 deletion server/controllers/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
func HandleUserSearch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

// Extract parameters
q := r.URL.Query().Get("q")
pageStr := r.URL.Query().Get("page")
limitStr := r.URL.Query().Get("limit")
Expand Down
1 change: 0 additions & 1 deletion server/controllers/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ func HandleWSUpgrade(w http.ResponseWriter, r *http.Request) {

services.Hub.Register <- client

// Kick off matching concurrency workers
go client.WritePump()
go client.ReadPump()
}
3 changes: 1 addition & 2 deletions server/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ func init() {
}
}

// assign the values with fallbacks
HOST = getEnv("HOST", "127.0.0.1")
PORT = getEnv("PORT", "3000")

Expand All @@ -64,4 +63,4 @@ func getEnv(key, fallback string) string {
return value
}
return fallback
}
}
1 change: 0 additions & 1 deletion server/middlewares/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import "net/http"

func CorsHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allows all origins for development; can be bound to specific domains via env later
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
Expand Down
5 changes: 5 additions & 0 deletions server/migrations/007_add_message_reply_to_up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE messages ADD COLUMN reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_messages_reply_to
ON messages(reply_to_message_id)
WHERE reply_to_message_id IS NOT NULL;
25 changes: 17 additions & 8 deletions server/models/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ package models

import "time"

// QuotedMessage represents a lightweight snapshot of the parent message being replied to
type QuotedMessage struct {
ID string `json:"id"`
SenderID string `json:"sender_id"`
Content string `json:"content"`
}

type Message struct {
ID string `json:"id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
IsRead bool `json:"is_read"`
}
ID string `json:"id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
IsRead bool `json:"is_read"`
ReplyToMessageID *string `json:"reply_to_message_id,omitempty"`
QuotedMessage *QuotedMessage `json:"quoted_message,omitempty"`
}
15 changes: 8 additions & 7 deletions server/models/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package models
import "time"

type WSMessage struct {
Type string `json:"type"`
SenderID string `json:"sender_id,omitempty"`
ReceiverID string `json:"receiver_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
Content string `json:"content,omitempty"`
MessageID string `json:"message_id,omitempty"`
Timestamp time.Time `json:"timestamp,omitempty"`
Type string `json:"type"`
SenderID string `json:"sender_id,omitempty"`
ReceiverID string `json:"receiver_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
Content string `json:"content,omitempty"`
MessageID string `json:"message_id,omitempty"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
Timestamp time.Time `json:"timestamp,omitempty"`
}
30 changes: 22 additions & 8 deletions server/repository/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,13 @@ func (r *GroupRepository) GetGroupMembers(ctx context.Context, groupID string) (

func (r *GroupRepository) GetGroupMessages(ctx context.Context, groupID string, before time.Time, limit int) ([]models.Message, error) {
query := `
SELECT id, sender_id, content, created_at
FROM messages
WHERE group_id = $1 AND created_at < $2
ORDER BY created_at DESC
SELECT
m.id, m.sender_id, m.content, m.created_at, m.reply_to_message_id,
q.sender_id AS quoted_sender_id, q.content AS quoted_content
FROM messages m
LEFT JOIN messages q ON m.reply_to_message_id = q.id
WHERE m.group_id = $1::uuid AND m.created_at < $2
ORDER BY m.created_at DESC
LIMIT $3;
`
rows, err := config.DB.Query(ctx, query, groupID, before, limit)
Expand All @@ -117,16 +120,27 @@ func (r *GroupRepository) GetGroupMessages(ctx context.Context, groupID string,
}
defer rows.Close()

messages := []models.Message{}
var history []models.Message = []models.Message{}
for rows.Next() {
var m models.Message
if err := rows.Scan(&m.ID, &m.SenderID, &m.Content, &m.CreatedAt); err != nil {
var qSender, qContent *string

err := rows.Scan(&m.ID, &m.SenderID, &m.Content, &m.CreatedAt, &m.ReplyToMessageID, &qSender, &qContent)
if err != nil {
return nil, err
}

m.GroupID = groupID
messages = append(messages, m)
if m.ReplyToMessageID != nil && qSender != nil && qContent != nil {
m.QuotedMessage = &models.QuotedMessage{
ID: *m.ReplyToMessageID,
SenderID: *qSender,
Content: *qContent,
}
}
history = append(history, m)
}
return messages, nil
return history, nil
}

func (r *GroupRepository) GetMembersDetails(ctx context.Context, groupID string) ([]models.GroupMemberDetail, error) {
Expand Down
46 changes: 35 additions & 11 deletions server/repository/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,19 @@ func NewMessageRepository() *MessageRepository {
return &MessageRepository{}
}

func (r *MessageRepository) CreateMessage(ctx context.Context, senderID, receiverID, content string) (*models.Message, error) {
func (r *MessageRepository) CreateMessage(ctx context.Context, senderID, receiverID, content, replyToID string) (*models.Message, error) {
query := `
INSERT INTO messages (sender_id, receiver_id, content)
VALUES ($1, $2, $3)
RETURNING id, sender_id, receiver_id, content, created_at;
INSERT INTO messages (sender_id, receiver_id, content, reply_to_message_id)
VALUES ($1, $2, $3, NULLIF($4, '')::uuid)
RETURNING id, sender_id, receiver_id, content, reply_to_message_id, created_at;
`
var msg models.Message
err := config.DB.QueryRow(ctx, query, senderID, receiverID, content).Scan(
err := config.DB.QueryRow(ctx, query, senderID, receiverID, content, replyToID).Scan(
&msg.ID,
&msg.SenderID,
&msg.ReceiverID,
&msg.Content,
&msg.ReplyToMessageID,
&msg.CreatedAt,
)
if err != nil {
Expand All @@ -36,11 +37,14 @@ func (r *MessageRepository) CreateMessage(ctx context.Context, senderID, receive

func (r *MessageRepository) GetChatHistory(ctx context.Context, userA, userB string, before time.Time, limit int) ([]models.Message, error) {
query := `
SELECT id, sender_id, receiver_id, content, created_at, is_read
FROM messages
WHERE ((sender_id = $1 AND receiver_id = $2) OR (sender_id = $2 AND receiver_id = $1))
AND created_at < $3
ORDER BY created_at DESC
SELECT
m.id, m.sender_id, m.receiver_id, m.content, m.created_at, m.is_read, m.reply_to_message_id,
q.sender_id AS quoted_sender_id, q.content AS quoted_content
FROM messages m
LEFT JOIN messages q ON m.reply_to_message_id = q.id
WHERE ((m.sender_id = $1 AND m.receiver_id = $2) OR (m.sender_id = $2 AND m.receiver_id = $1))
AND m.group_id IS NULL AND m.created_at < $3
ORDER BY m.created_at DESC
LIMIT $4;
`
rows, err := config.DB.Query(ctx, query, userA, userB, before, limit)
Expand All @@ -52,15 +56,35 @@ func (r *MessageRepository) GetChatHistory(ctx context.Context, userA, userB str
var history []models.Message = []models.Message{}
for rows.Next() {
var m models.Message
err := rows.Scan(&m.ID, &m.SenderID, &m.ReceiverID, &m.Content, &m.CreatedAt, &m.IsRead)
var qSender, qContent *string

err := rows.Scan(&m.ID, &m.SenderID, &m.ReceiverID, &m.Content, &m.CreatedAt, &m.IsRead, &m.ReplyToMessageID, &qSender, &qContent)
if err != nil {
return nil, err
}

if m.ReplyToMessageID != nil && qSender != nil && qContent != nil {
m.QuotedMessage = &models.QuotedMessage{
ID: *m.ReplyToMessageID,
SenderID: *qSender,
Content: *qContent,
}
}
history = append(history, m)
}
return history, nil
}

func (r *MessageRepository) GetMessagePreview(ctx context.Context, messageID string) (*models.QuotedMessage, error) {
query := `SELECT id, sender_id, content FROM messages WHERE id = $1::uuid;`
var q models.QuotedMessage
err := config.DB.QueryRow(ctx, query, messageID).Scan(&q.ID, &q.SenderID, &q.Content)
if err != nil {
return nil, err
}
return &q, nil
}

func (r *MessageRepository) GetConversations(ctx context.Context, userID string) ([]models.Conversation, error) {
query := `
WITH raw_conversations AS (
Expand Down
1 change: 0 additions & 1 deletion server/repository/user_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"github.com/commandlinecoding/elephant/server/config"
)

// will have to remove manually deleteing users. Will be updated after delete users function is implemented
func TestUserRepository_create_and_search(t *testing.T) {
ctx := context.Background()

Expand Down
6 changes: 3 additions & 3 deletions server/services/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ func NewMessageService() *MessageService {
return &MessageService{repo: repository.NewMessageRepository()}
}

func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID, content string) (*models.Message, error) {
func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID, content, replyToMessageID string) (*models.Message, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("message content cannot be empty")
}
if senderID == receiverID {
return nil, errors.New("cannot send a message to yourself")
}
return s.repo.CreateMessage(ctx, senderID, receiverID, content)
}
return s.repo.CreateMessage(ctx, senderID, receiverID, content, replyToMessageID)
}
36 changes: 31 additions & 5 deletions server/services/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,16 +222,43 @@ func (c *WSClient) ReadPump() {

go func(msg models.WSMessage) {
query := `
INSERT INTO messages (sender_id, receiver_id, group_id, content, id)
VALUES ($1::uuid, NULLIF($2, '')::uuid, NULLIF($3, '')::uuid, $4, COALESCE(NULLIF($5, '')::uuid, gen_random_uuid()));
INSERT INTO messages (sender_id, receiver_id, group_id, content, id, reply_to_message_id)
VALUES (
$1::uuid,
NULLIF($2, '')::uuid,
NULLIF($3, '')::uuid,
$4,
COALESCE(NULLIF($5, '')::uuid, gen_random_uuid()),
NULLIF($6, '')::uuid
);
`
_, err := config.DB.Exec(context.Background(), query, msg.SenderID, msg.ReceiverID, msg.GroupID, msg.Content, msg.MessageID)
_, err := config.DB.Exec(context.Background(), query, msg.SenderID, msg.ReceiverID, msg.GroupID, msg.Content, msg.MessageID, msg.ReplyToMessageID)
if err != nil {
log.Printf("Async group/direct write failure: %v", err)
}
}(incoming)

outboundBytes, _ := json.Marshal(incoming)
var outboundBytes []byte
if incoming.ReplyToMessageID != "" {
if q, err := msgRepo.GetMessagePreview(context.Background(), incoming.ReplyToMessageID); err == nil {
enriched := map[string]interface{}{
"type": incoming.Type,
"sender_id": incoming.SenderID,
"receiver_id": incoming.ReceiverID,
"group_id": incoming.GroupID,
"content": incoming.Content,
"message_id": incoming.MessageID,
"reply_to_message_id": incoming.ReplyToMessageID,
"timestamp": incoming.Timestamp,
"quoted_message": q,
}
outboundBytes, _ = json.Marshal(enriched)
} else {
outboundBytes, _ = json.Marshal(incoming)
}
} else {
outboundBytes, _ = json.Marshal(incoming)
}

if incoming.GroupID != "" {
groupRepo := repository.NewGroupRepository()
Expand All @@ -256,7 +283,6 @@ func (c *WSClient) ReadPump() {
}
}
Hub.mu.RUnlock()

} else {
Hub.mu.RLock()
targetClient, online := Hub.clients[strings.ToLower(incoming.ReceiverID)]
Expand Down
Loading