Building a Messenger App: Conversations
This post is the 3rd in a series:
In our messenger app, messages are stacked by conversations between two participants. You start a conversation providing the user you want to chat with, the conversations is created (if not exists already) and you can start sending messages to that conversations.
On the front-end we’re interested in showing a list of the lastest conversations. There we’ll show the last message of it and the name and avatar of the other participant.
In this post, we’ll code the endpoints to start a conversation, list the latest and find a single one.
Inside the main()
function add this routes.
router.HandleFunc("POST", "/api/conversations", requireJSON(guard(createConversation)))
router.HandleFunc("GET", "/api/conversations", guard(getConversations))
router.HandleFunc("GET", "/api/conversations/:conversationID", guard(getConversation))
These three endpoints require authentication so we use the guard()
middleware. There is a new middleware that checks for the request content type JSON.
Require JSON Middleware
func requireJSON(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
http.Error(w, "Content type of application/json required", http.StatusUnsupportedMediaType)
return
}
handler(w, r)
}
}
If the request isn’t JSON, it responds with a 415 Unsupported Media Type
error.
Create Conversation
type Conversation struct {
ID string `json:"id"`
OtherParticipant *User `json:"otherParticipant"`
LastMessage *Message `json:"lastMessage"`
HasUnreadMessages bool `json:"hasUnreadMessages"`
}
So, a conversation holds a reference to the other participant and the last message. Also has a bool field to tell if it has unread messages.
type Message struct {
ID string `json:"id"`
Content string `json:"content"`
UserID string `json:"-"`
ConversationID string `json:"conversationID,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Mine bool `json:"mine"`
ReceiverID string `json:"-"`
}
Messages are for the next post, but I define the struct now since we are using it. Most of the fields are the same as the database table. We have Mine
to tell if the message is owned by the current authenticated user and ReceiverID
will be used to filter messanges once we add realtime capabilities.
Lets write the HTTP handler then. It’s quite long but don’t be scared.
func createConversation(w http.ResponseWriter, r *http.Request) {
var input struct {
Username string `json:"username"`
}
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
input.Username = strings.TrimSpace(input.Username)
if input.Username == "" {
respond(w, Errors{map[string]string{
"username": "Username required",
}}, http.StatusUnprocessableEntity)
return
}
ctx := r.Context()
authUserID := ctx.Value(keyAuthUserID).(string)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
respondError(w, fmt.Errorf("could not begin tx: %v", err))
return
}
defer tx.Rollback()
var otherParticipant User
if err := tx.QueryRowContext(ctx, `
SELECT id, avatar_url FROM users WHERE username = $1
`, input.Username).Scan(
&otherParticipant.ID,
&otherParticipant.AvatarURL,
); err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
} else if err != nil {
respondError(w, fmt.Errorf("could not query other participant: %v", err))
return
}
otherParticipant.Username = input.Username
if otherParticipant.ID == authUserID {
http.Error(w, "Try start a conversation with someone else", http.StatusForbidden)
return
}
var conversationID string
if err := tx.QueryRowContext(ctx, `
SELECT conversation_id FROM participants WHERE user_id = $1
INTERSECT
SELECT conversation_id FROM participants WHERE user_id = $2
`, authUserID, otherParticipant.ID).Scan(&conversationID); err != nil && err != sql.ErrNoRows {
respondError(w, fmt.Errorf("could not query common conversation id: %v", err))
return
} else if err == nil {
http.Redirect(w, r, "/api/conversations/"+conversationID, http.StatusFound)
return
}
var conversation Conversation
if err = tx.QueryRowContext(ctx, `
INSERT INTO conversations DEFAULT VALUES
RETURNING id
`).Scan(&conversation.ID); err != nil {
respondError(w, fmt.Errorf("could not insert conversation: %v", err))
return
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO participants (user_id, conversation_id) VALUES
($1, $2),
($3, $2)
`, authUserID, conversation.ID, otherParticipant.ID); err != nil {
respondError(w, fmt.Errorf("could not insert participants: %v", err))
return
}
if err = tx.Commit(); err != nil {
respondError(w, fmt.Errorf("could not commit tx to create conversation: %v", err))
return
}
conversation.OtherParticipant = &otherParticipant
respond(w, conversation, http.StatusCreated)
}
For this endpoint you do a POST request to /api/conversations
with a JSON body containing the username of the user you want to chat with.
So first it decodes the request body into an struct with the username. Then it validates that the username is not empty.
type Errors struct {
Errors map[string]string `json:"errors"`
}
This is the Errors
struct. It’s just a map. If you enter an empty username you get this JSON with a 422 Unprocessable Entity
error.
{
"errors": {
"username": "Username required"
}
}
Then, we begin an SQL transaction.
We only received an username, but we need the actual user ID. So the first part of the transaction is to query for the id and avatar of that user (the other participant). If the user is not found, we respond with a 404 Not Found
error. Also, if the user happens to be the same as the current authenticated user, we respond with 403 Forbidden
. There should be two different users, not the same.
Then, we try to find a conversation those two users have in common. We use INTERSECT
for that. If there is one, we redirect to that conversation /api/conversations/{conversationID}
and return there.
If no common conversation was found, we continue by creating a new one and adding the two participants. Finally, we COMMIT
the transaction and respond with the newly created conversation.
Get Conversations
This endpoint /api/conversations
is to get all the conversations of the current authenticated user.
func getConversations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authUserID := ctx.Value(keyAuthUserID).(string)
rows, err := db.QueryContext(ctx, `
SELECT
conversations.id,
auth_user.messages_read_at < messages.created_at AS has_unread_messages,
messages.id,
messages.content,
messages.created_at,
messages.user_id = $1 AS mine,
other_users.id,
other_users.username,
other_users.avatar_url
FROM conversations
INNER JOIN messages ON conversations.last_message_id = messages.id
INNER JOIN participants other_participants
ON other_participants.conversation_id = conversations.id
AND other_participants.user_id != $1
INNER JOIN users other_users ON other_participants.user_id = other_users.id
INNER JOIN participants auth_user
ON auth_user.conversation_id = conversations.id
AND auth_user.user_id = $1
ORDER BY messages.created_at DESC
`, authUserID)
if err != nil {
respondError(w, fmt.Errorf("could not query conversations: %v", err))
return
}
defer rows.Close()
conversations := make([]Conversation, 0)
for rows.Next() {
var conversation Conversation
var lastMessage Message
var otherParticipant User
if err = rows.Scan(
&conversation.ID,
&conversation.HasUnreadMessages,
&lastMessage.ID,
&lastMessage.Content,
&lastMessage.CreatedAt,
&lastMessage.Mine,
&otherParticipant.ID,
&otherParticipant.Username,
&otherParticipant.AvatarURL,
); err != nil {
respondError(w, fmt.Errorf("could not scan conversation: %v", err))
return
}
conversation.LastMessage = &lastMessage
conversation.OtherParticipant = &otherParticipant
conversations = append(conversations, conversation)
}
if err = rows.Err(); err != nil {
respondError(w, fmt.Errorf("could not iterate over conversations: %v", err))
return
}
respond(w, conversations, http.StatusOK)
}
This handler just does a query to the database. It queries to the conversations table with some joins… First, to the messages table to get the last message. Then to the participants, but it adds a condition to a participant whose ID is not the one of the current authenticated user; this is the other participant. Then it joins to the users table to get his username and avatar. And finally joins with the participants again but with the contrary condition, so this participant is the current authenticated user. We compare messages_read_at
with the message created_at
to know whether the conversation has unread messages. And we use the message user_id
to check if it’s “mine” or not.
Note that this query assumes that a conversation has just two users. It only works for that scenario. Also, if you want to show a count of the unread messages, this design isn’t good. I think you could add a unread_messages_count
INT
field on the participants
table and increment it each time a new message is created and reset it when the user read them.
Then it iterates over the rows, scan each one to make an slice of conversations and respond with those at the end.
Get Conversation
This endpoint /api/conversations/{conversationID}
respond with a single conversation by its ID.
func getConversation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authUserID := ctx.Value(keyAuthUserID).(string)
conversationID := way.Param(ctx, "conversationID")
var conversation Conversation
var otherParticipant User
if err := db.QueryRowContext(ctx, `
SELECT
IFNULL(auth_user.messages_read_at < messages.created_at, false) AS has_unread_messages,
other_users.id,
other_users.username,
other_users.avatar_url
FROM conversations
LEFT JOIN messages ON conversations.last_message_id = messages.id
INNER JOIN participants other_participants
ON other_participants.conversation_id = conversations.id
AND other_participants.user_id != $1
INNER JOIN users other_users ON other_participants.user_id = other_users.id
INNER JOIN participants auth_user
ON auth_user.conversation_id = conversations.id
AND auth_user.user_id = $1
WHERE conversations.id = $2
`, authUserID, conversationID).Scan(
&conversation.HasUnreadMessages,
&otherParticipant.ID,
&otherParticipant.Username,
&otherParticipant.AvatarURL,
); err == sql.ErrNoRows {
http.Error(w, "Conversation not found", http.StatusNotFound)
return
} else if err != nil {
respondError(w, fmt.Errorf("could not query conversation: %v", err))
return
}
conversation.ID = conversationID
conversation.OtherParticipant = &otherParticipant
respond(w, conversation, http.StatusOK)
}
The query is quite similar. We’re not interested in showing the last message, so we omit those fields, but we need the message to know whether the conversation has unread messages. This time we do a LEFT JOIN
instead of an INNER JOIN
because the last_message_id
is NULLABLE
; in other case we won’t get any rows. We use an IFNULL
in the has_unread_messages
comparison for that reason too. Lastly, we filter by ID.
If the query returns no rows, we respond with a 404 Not Found
error, otherwise 200 OK
with the found conversation.
Yeah, that concludes with the conversation endpoints.
Wait for the next post to create and list messages 👋