Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
02178ba
docs: add read receipts design
AchoArnold Jul 18, 2026
f8f0800
docs: add read receipts implementation plan
AchoArnold Jul 18, 2026
9fcec89
docs: add read receipts integration coverage
AchoArnold Jul 18, 2026
1e6259f
feat(api): add thread read state contract
AchoArnold Jul 18, 2026
d279bd9
refactor(api): make thread updates atomic
AchoArnold Jul 18, 2026
6add7f1
feat(api): track unread inbound threads
AchoArnold Jul 18, 2026
6e0bc3b
docs(api): publish thread read state
AchoArnold Jul 18, 2026
cd09403
style(api): format thread handler test
AchoArnold Jul 18, 2026
ea78e62
feat(web): persist opened threads as read
AchoArnold Jul 18, 2026
88bc64d
chore(web): keep read store change scoped
AchoArnold Jul 18, 2026
de37899
fix(web): preserve thread update failures
AchoArnold Jul 18, 2026
dca7762
feat(web): highlight unread message threads
AchoArnold Jul 18, 2026
24fef0b
test: cover message thread read receipts
AchoArnold Jul 18, 2026
3fc71ba
test: preserve event timestamp precision
AchoArnold Jul 18, 2026
c7482d9
fix(api): persist new unread threads
AchoArnold Jul 18, 2026
4544219
style(api): apply gofumpt to handler test
AchoArnold Jul 18, 2026
c5b6b06
Merge origin/main into feat/read-receipts
AchoArnold Jul 18, 2026
879230b
test(api): update listener service setup
AchoArnold Jul 18, 2026
63215d1
Merge latest origin/main into feat/read-receipts
AchoArnold Jul 18, 2026
a5451ea
fix: harden read receipt synchronization
AchoArnold Jul 18, 2026
b5a7db2
docs: normalize plan code indentation
AchoArnold Jul 18, 2026
ba466a7
fix(api): return updated thread atomically
AchoArnold Jul 18, 2026
b9c5971
refactor(api): update activity atomically
AchoArnold Jul 19, 2026
fa04695
style: fix thread ui for unread threads
AchoArnold Jul 19, 2026
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
41 changes: 37 additions & 4 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/responses.PhoneResponse"
"$ref": "#/definitions/responses.MessageThreadResponse"
}
},
"400": {
Expand All @@ -886,6 +886,12 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/responses.NotFound"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
Expand Down Expand Up @@ -3691,6 +3697,7 @@ const docTemplate = `{
"created_at",
"id",
"is_archived",
"is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
Expand Down Expand Up @@ -3720,6 +3727,10 @@ const docTemplate = `{
"type": "boolean",
"example": false
},
"is_read": {
"type": "boolean",
"example": true
},
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
Expand Down Expand Up @@ -4388,13 +4399,14 @@ const docTemplate = `{
},
"requests.MessageThreadUpdate": {
"type": "object",
"required": [
"is_archived"
],
"properties": {
"is_archived": {
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
}
}
},
Expand Down Expand Up @@ -4904,6 +4916,27 @@ const docTemplate = `{
}
}
},
"responses.MessageThreadResponse": {
"type": "object",
"required": [
"data",
"message",
"status"
],
"properties": {
"data": {
"$ref": "#/definitions/entities.MessageThread"
},
"message": {
"type": "string",
"example": "Request handled successfully"
},
"status": {
"type": "string",
"example": "success"
}
}
},
"responses.MessageThreadsResponse": {
"type": "object",
"required": [
Expand Down
41 changes: 37 additions & 4 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/responses.PhoneResponse"
"$ref": "#/definitions/responses.MessageThreadResponse"
}
},
"400": {
Expand All @@ -883,6 +883,12 @@
"$ref": "#/definitions/responses.Unauthorized"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/responses.NotFound"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
Expand Down Expand Up @@ -3688,6 +3694,7 @@
"created_at",
"id",
"is_archived",
"is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
Expand Down Expand Up @@ -3717,6 +3724,10 @@
"type": "boolean",
"example": false
},
"is_read": {
"type": "boolean",
"example": true
},
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
Expand Down Expand Up @@ -4385,13 +4396,14 @@
},
"requests.MessageThreadUpdate": {
"type": "object",
"required": [
"is_archived"
],
"properties": {
"is_archived": {
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
}
}
},
Expand Down Expand Up @@ -4901,6 +4913,27 @@
}
}
},
"responses.MessageThreadResponse": {
"type": "object",
"required": [
"data",
"message",
"status"
],
"properties": {
"data": {
"$ref": "#/definitions/entities.MessageThread"
},
"message": {
"type": "string",
"example": "Request handled successfully"
},
"status": {
"type": "string",
"example": "success"
}
}
},
"responses.MessageThreadsResponse": {
"type": "object",
"required": [
Expand Down
30 changes: 27 additions & 3 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ definitions:
is_archived:
example: false
type: boolean
is_read:
example: true
type: boolean
last_message_content:
example: This is a sample message content
type: string
Expand Down Expand Up @@ -346,6 +349,7 @@ definitions:
- created_at
- id
- is_archived
- is_read
- last_message_content
- last_message_id
- order_timestamp
Expand Down Expand Up @@ -853,8 +857,9 @@ definitions:
is_archived:
example: true
type: boolean
required:
- is_archived
is_read:
example: true
type: boolean
type: object
requests.PhoneAPIKeyStoreRequest:
properties:
Expand Down Expand Up @@ -1227,6 +1232,21 @@ definitions:
- message
- status
type: object
responses.MessageThreadResponse:
properties:
data:
$ref: '#/definitions/entities.MessageThread'
message:
example: Request handled successfully
type: string
status:
example: success
type: string
required:
- data
- message
- status
type: object
responses.MessageThreadsResponse:
properties:
data:
Expand Down Expand Up @@ -2182,7 +2202,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/responses.PhoneResponse'
$ref: '#/definitions/responses.MessageThreadResponse'
"400":
description: Bad Request
schema:
Expand All @@ -2191,6 +2211,10 @@ paths:
description: Unauthorized
schema:
$ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
$ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
Expand Down
2 changes: 2 additions & 0 deletions api/pkg/entities/message_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type MessageThread struct {
Owner string `json:"owner" example:"+18005550199"`
Contact string `json:"contact" example:"+18005550100"`
IsArchived bool `json:"is_archived" example:"false"`
IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"`
LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"`
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Color string `json:"color" example:"indigo"`
Status MessageStatus `json:"status" example:"PENDING"`
Expand Down
25 changes: 25 additions & 0 deletions api/pkg/entities/message_thread_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package entities

import (
"reflect"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) {
threadType := reflect.TypeOf(MessageThread{})

isRead, ok := threadType.FieldByName("IsRead")
require.True(t, ok)
assert.Contains(t, isRead.Tag.Get("gorm"), "not null")
assert.Contains(t, isRead.Tag.Get("gorm"), "default:true")
assert.Equal(t, "is_read", isRead.Tag.Get("json"))

lastReadAt, ok := threadType.FieldByName("LastReadAt")
require.True(t, ok)
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null")
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP")
assert.Equal(t, "-", lastReadAt.Tag.Get("json"))
}
6 changes: 5 additions & 1 deletion api/pkg/handlers/message_thread_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error {
// @Produce json
// @Param messageThreadID path string true "ID of the message thread" default(32343a19-da5e-4b1b-a767-3298a73703ca)
// @Param payload body requests.MessageThreadUpdate true "Payload of message thread details to update"
// @Success 200 {object} responses.PhoneResponse
// @Success 200 {object} responses.MessageThreadResponse
// @Failure 400 {object} responses.BadRequest
// @Failure 401 {object} responses.Unauthorized
// @Failure 404 {object} responses.NotFound
// @Failure 422 {object} responses.UnprocessableEntity
// @Failure 500 {object} responses.InternalServerError
// @Router /message-threads/{messageThreadID} [put]
Expand All @@ -123,6 +124,9 @@ func (h *MessageThreadHandler) Update(c fiber.Ctx) error {
}

thread, err := h.service.UpdateStatus(ctx, request.ToUpdateParams(h.userIDFomContext(c)))
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID))
}
if err != nil {
ctxLogger.Error(stacktrace.Propagate(err, "cannot update message thread with params [%+#v]", request))
return h.responseInternalServerError(c)
Expand Down
Loading
Loading