diff --git a/api/docs/docs.go b/api/docs/docs.go
index 8eb6dafe..96cf0e55 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -4129,12 +4129,12 @@ const docTemplate = `{
"created_at",
"id",
"is_archived",
- "is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
"owner",
"status",
+ "unread_count",
"updated_at",
"user_id"
],
@@ -4167,10 +4167,6 @@ const docTemplate = `{
"type": "boolean",
"example": false
},
- "is_read": {
- "type": "boolean",
- "example": true
- },
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
@@ -4191,6 +4187,10 @@ const docTemplate = `{
"type": "string",
"example": "PENDING"
},
+ "unread_count": {
+ "type": "integer",
+ "example": 0
+ },
"updated_at": {
"type": "string",
"example": "2022-06-05T14:26:09.527976+03:00"
@@ -4920,9 +4920,9 @@ const docTemplate = `{
"type": "boolean",
"example": true
},
- "is_read": {
- "type": "boolean",
- "example": true
+ "unread_count": {
+ "type": "integer",
+ "example": 0
}
}
},
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index 21c6bbf8..4926b5e5 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -4126,12 +4126,12 @@
"created_at",
"id",
"is_archived",
- "is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
"owner",
"status",
+ "unread_count",
"updated_at",
"user_id"
],
@@ -4164,10 +4164,6 @@
"type": "boolean",
"example": false
},
- "is_read": {
- "type": "boolean",
- "example": true
- },
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
@@ -4188,6 +4184,10 @@
"type": "string",
"example": "PENDING"
},
+ "unread_count": {
+ "type": "integer",
+ "example": 0
+ },
"updated_at": {
"type": "string",
"example": "2022-06-05T14:26:09.527976+03:00"
@@ -4917,9 +4917,9 @@
"type": "boolean",
"example": true
},
- "is_read": {
- "type": "boolean",
- "example": true
+ "unread_count": {
+ "type": "integer",
+ "example": 0
}
}
},
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index 4fe2f2b7..9cfefbf5 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -367,9 +367,6 @@ 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
@@ -385,6 +382,9 @@ definitions:
status:
example: PENDING
type: string
+ unread_count:
+ example: 0
+ type: integer
updated_at:
example: "2022-06-05T14:26:09.527976+03:00"
type: string
@@ -397,12 +397,12 @@ definitions:
- created_at
- id
- is_archived
- - is_read
- last_message_content
- last_message_id
- order_timestamp
- owner
- status
+ - unread_count
- updated_at
- user_id
type: object
@@ -956,9 +956,9 @@ definitions:
is_archived:
example: true
type: boolean
- is_read:
- example: true
- type: boolean
+ unread_count:
+ example: 0
+ type: integer
type: object
requests.PhoneAPIKeyStoreRequest:
properties:
diff --git a/api/pkg/entities/message_thread.go b/api/pkg/entities/message_thread.go
index d4d893a4..e8f1f687 100644
--- a/api/pkg/entities/message_thread.go
+++ b/api/pkg/entities/message_thread.go
@@ -9,12 +9,11 @@ import (
// MessageThread represents a message thread between 2 phone numbers
type MessageThread struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703ca"`
- Owner string `json:"owner" example:"+18005550199"`
- Contact string `json:"contact" example:"+18005550100"`
+ Owner string `json:"owner" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:2" example:"+18005550199"`
+ Contact string `json:"contact" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:3" 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"`
+ UnreadCount uint `json:"unread_count" gorm:"not null;default:0" example:"0"`
+ UserID UserID `json:"user_id" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:1" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Color string `json:"color" example:"indigo"`
Status MessageStatus `json:"status" example:"PENDING"`
LastMessageContent *string `json:"last_message_content" example:"This is a sample message content"`
diff --git a/api/pkg/entities/message_thread_test.go b/api/pkg/entities/message_thread_test.go
index b587bacb..47eaf277 100644
--- a/api/pkg/entities/message_thread_test.go
+++ b/api/pkg/entities/message_thread_test.go
@@ -9,20 +9,29 @@ import (
"github.com/stretchr/testify/require"
)
-func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) {
+func TestMessageThreadUnreadCountHasDefault(t *testing.T) {
threadType := reflect.TypeOf(MessageThread{})
- isRead, ok := threadType.FieldByName("IsRead")
+ unreadCount, ok := threadType.FieldByName("UnreadCount")
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"))
+ assert.Contains(t, unreadCount.Tag.Get("gorm"), "not null")
+ assert.Contains(t, unreadCount.Tag.Get("gorm"), "default:0")
+ assert.Equal(t, "unread_count", unreadCount.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"))
+func TestMessageThreadHasUniqueOwnerContactPerUser(t *testing.T) {
+ threadType := reflect.TypeOf(MessageThread{})
+
+ expectedTags := map[string]string{
+ "UserID": "uniqueIndex:idx_message_threads_user_owner_contact,priority:1",
+ "Owner": "uniqueIndex:idx_message_threads_user_owner_contact,priority:2",
+ "Contact": "uniqueIndex:idx_message_threads_user_owner_contact,priority:3",
+ }
+ for fieldName, expectedTag := range expectedTags {
+ field, ok := threadType.FieldByName(fieldName)
+ require.True(t, ok)
+ assert.Contains(t, field.Tag.Get("gorm"), expectedTag)
+ }
}
func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) {
diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go
index 56dfa0c7..a304f4ad 100644
--- a/api/pkg/handlers/message_thread_handler_test.go
+++ b/api/pkg/handlers/message_thread_handler_test.go
@@ -75,7 +75,7 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin
handler.RegisterRoutes(app)
messageThreadID := uuid.New()
- req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"is_read":true}`))
+ req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"unread_count":0}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go
index 09241804..3989cc4e 100644
--- a/api/pkg/repositories/gorm_message_thread_repository.go
+++ b/api/pkg/repositories/gorm_message_thread_repository.go
@@ -46,11 +46,7 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string
updates["is_archived"] = false
}
if params.MarkAsUnread {
- updates["is_read"] = gorm.Expr(
- "CASE WHEN last_read_at < ? THEN ? ELSE is_read END",
- params.EventTimestamp,
- false,
- )
+ updates["unread_count"] = gorm.Expr("unread_count + ?", 1)
}
return updates
}
@@ -68,11 +64,8 @@ func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any
if params.IsArchived != nil {
updates["is_archived"] = *params.IsArchived
}
- if params.IsRead != nil {
- updates["is_read"] = *params.IsRead
- if *params.IsRead {
- updates["last_read_at"] = params.ReadAt
- }
+ if params.UnreadCount != nil {
+ updates["unread_count"] = *params.UnreadCount
}
return updates
}
@@ -123,27 +116,24 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread
ctx, span := repository.tracer.Start(ctx)
defer span.End()
- isRead := thread.IsRead
- err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(thread)
- thread.IsRead = isRead
- if result.Error != nil {
- return result.Error
- }
- if result.RowsAffected == 0 || isRead {
- return nil
- }
-
- return tx.Model(&entities.MessageThread{}).
- Where("user_id = ?", thread.UserID).
- Where("id = ?", thread.ID).
- UpdateColumn("is_read", false).
- Error
- })
- if err != nil {
- return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save message thread with ID [%s]", thread.ID))
+ db := repository.db.WithContext(ctx).Session(&gorm.Session{SkipDefaultTransaction: true})
+ onConflict := clause.OnConflict{
+ Columns: []clause.Column{
+ {Name: "user_id"},
+ {Name: "owner"},
+ {Name: "contact"},
+ },
+ DoNothing: thread.UnreadCount == 0,
+ }
+ if thread.UnreadCount > 0 {
+ onConflict.DoUpdates = clause.Assignments(map[string]any{
+ "unread_count": gorm.Expr("unread_count + ?", 1),
+ })
}
+ if result := db.Clauses(onConflict).Create(thread); result.Error != nil {
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot insert message thread with ID [%s]", thread.ID))
+ }
return nil
}
diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go
index 31dc43ed..c30ed4fc 100644
--- a/api/pkg/repositories/gorm_message_thread_repository_test.go
+++ b/api/pkg/repositories/gorm_message_thread_repository_test.go
@@ -75,7 +75,7 @@ func (logger *messageThreadTestLogger) Debug(string)
func (logger *messageThreadTestLogger) Fatal(error) {}
func (logger *messageThreadTestLogger) Printf(string, ...interface{}) {}
-func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) {
+func TestMessageThreadStoreIncrementsUnreadCountOnConflict(t *testing.T) {
pool := &messageThreadTestConnPool{}
db, err := gorm.Open(
postgres.New(postgres.Config{
@@ -89,18 +89,17 @@ func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) {
logger := &messageThreadTestLogger{}
repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db)
thread := &entities.MessageThread{
- ID: uuid.New(),
- IsRead: false,
+ ID: uuid.New(),
+ UnreadCount: 1,
}
require.NoError(t, repository.Store(context.Background(), thread))
- assert.False(t, thread.IsRead)
+ assert.Equal(t, uint(1), thread.UnreadCount)
require.NotEmpty(t, pool.statements)
- update := pool.statements[len(pool.statements)-1]
- assert.True(t, strings.HasPrefix(update.query, `UPDATE "message_threads"`))
- assert.Contains(t, update.query, `"is_read"=$1`)
- assert.Contains(t, update.args, false)
+ insert := pool.statements[len(pool.statements)-1]
+ assert.True(t, strings.HasPrefix(insert.query, `INSERT INTO "message_threads"`))
+ assert.Contains(t, insert.query, `ON CONFLICT ("user_id","owner","contact") DO UPDATE SET "unread_count"=unread_count + $`)
}
func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) {
@@ -118,9 +117,8 @@ func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) {
"last_message_content": "hello",
"status": entities.MessageStatus(entities.MessageStatusReceived),
}, updates)
- assert.NotContains(t, updates, "is_read")
+ assert.NotContains(t, updates, "unread_count")
assert.NotContains(t, updates, "is_archived")
- assert.NotContains(t, updates, "last_read_at")
}
func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) {
@@ -156,7 +154,7 @@ func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) {
}
}
require.Len(t, updates, 1)
- assert.Contains(t, updates[0].query, `"is_read"=CASE WHEN last_read_at <`)
+ assert.Contains(t, updates[0].query, `"unread_count"=unread_count + $`)
}
func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) {
@@ -175,19 +173,14 @@ func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) {
}, updates)
}
-func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) {
- isRead := true
- readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC)
+func TestMessageThreadStatusUpdatesUnreadCountOnly(t *testing.T) {
+ unreadCount := uint(0)
updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{
- IsRead: &isRead,
- ReadAt: readAt,
+ UnreadCount: &unreadCount,
})
- assert.Equal(t, map[string]any{
- "is_read": true,
- "last_read_at": readAt,
- }, updates)
+ assert.Equal(t, map[string]any{"unread_count": uint(0)}, updates)
assert.NotContains(t, updates, "is_archived")
}
@@ -197,8 +190,6 @@ func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) {
updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{
IsArchived: &isArchived,
})
-
assert.Equal(t, map[string]any{"is_archived": true}, updates)
- assert.NotContains(t, updates, "is_read")
- assert.NotContains(t, updates, "last_read_at")
+ assert.NotContains(t, updates, "unread_count")
}
diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go
index e6093141..28efc11e 100644
--- a/api/pkg/repositories/message_thread_repository.go
+++ b/api/pkg/repositories/message_thread_repository.go
@@ -23,9 +23,8 @@ type MessageThreadActivityUpdate struct {
}
type MessageThreadStatusUpdate struct {
- IsArchived *bool
- IsRead *bool
- ReadAt time.Time
+ IsArchived *bool
+ UnreadCount *uint
}
type MessageThreadDeletedUpdate struct {
diff --git a/api/pkg/requests/message_thread_update_request.go b/api/pkg/requests/message_thread_update_request.go
index 3309fc91..0832ad81 100644
--- a/api/pkg/requests/message_thread_update_request.go
+++ b/api/pkg/requests/message_thread_update_request.go
@@ -10,8 +10,8 @@ import (
// MessageThreadUpdate is the payload for updating a message thread
type MessageThreadUpdate struct {
request
- IsArchived *bool `json:"is_archived,omitempty" example:"true"`
- IsRead *bool `json:"is_read,omitempty" example:"true"`
+ IsArchived *bool `json:"is_archived,omitempty" example:"true"`
+ UnreadCount *uint `json:"unread_count,omitempty" example:"0"`
MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` // used internally for validation
}
@@ -22,6 +22,6 @@ func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) service
UserID: userID,
MessageThreadID: uuid.MustParse(input.MessageThreadID),
IsArchived: input.IsArchived,
- IsRead: input.IsRead,
+ UnreadCount: input.UnreadCount,
}
}
diff --git a/api/pkg/requests/message_thread_update_request_test.go b/api/pkg/requests/message_thread_update_request_test.go
index 9f9579fd..58ba7e4f 100644
--- a/api/pkg/requests/message_thread_update_request_test.go
+++ b/api/pkg/requests/message_thread_update_request_test.go
@@ -10,10 +10,10 @@ import (
func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) {
threadID := uuid.New()
- isRead := true
+ unreadCount := uint(0)
input := MessageThreadUpdate{
MessageThreadID: threadID.String(),
- IsRead: &isRead,
+ UnreadCount: &unreadCount,
}
params := input.ToUpdateParams(entities.UserID("user-id"))
@@ -21,5 +21,5 @@ func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T)
assert.Equal(t, threadID, params.MessageThreadID)
assert.Equal(t, entities.UserID("user-id"), params.UserID)
assert.Nil(t, params.IsArchived)
- assert.Same(t, &isRead, params.IsRead)
+ assert.Same(t, &unreadCount, params.UnreadCount)
}
diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go
index 8431672b..efcfadd1 100644
--- a/api/pkg/services/message_thread_service.go
+++ b/api/pkg/services/message_thread_service.go
@@ -143,7 +143,7 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me
// MessageThreadStatusParams are parameters for updating a thread status
type MessageThreadStatusParams struct {
IsArchived *bool
- IsRead *bool
+ UnreadCount *uint
UserID entities.UserID
MessageThreadID uuid.UUID
}
@@ -154,9 +154,8 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me
defer span.End()
update := repositories.MessageThreadStatusUpdate{
- IsArchived: params.IsArchived,
- IsRead: params.IsRead,
- ReadAt: time.Now().UTC(),
+ IsArchived: params.IsArchived,
+ UnreadCount: params.UnreadCount,
}
thread, err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update)
if err != nil {
@@ -207,10 +206,13 @@ func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Conte
}
func (service *MessageThreadService) createThread(ctx context.Context, params MessageThreadUpdateParams) error {
- ctx, span := service.tracer.Start(ctx)
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
defer span.End()
- ctxLogger := service.tracer.CtxLogger(service.logger, span)
+ unreadCount := uint(0)
+ if params.MarkAsUnread {
+ unreadCount = 1
+ }
now := time.Now().UTC()
thread := &entities.MessageThread{
@@ -219,8 +221,7 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me
Contact: params.Contact,
UserID: params.UserID,
IsArchived: false,
- IsRead: !params.MarkAsUnread,
- LastReadAt: now,
+ UnreadCount: unreadCount,
Color: service.getColor(),
LastMessageContent: ¶ms.Content,
Status: params.Status,
diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go
index 44a3bcfd..1ef9b4bd 100644
--- a/api/pkg/services/message_thread_service_test.go
+++ b/api/pkg/services/message_thread_service_test.go
@@ -107,11 +107,11 @@ func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) {
assert.Equal(t, eventTimestamp, captured.EventTimestamp)
}
-func TestUpdateThreadPreservesReadStateForOutboundActivity(t *testing.T) {
+func TestUpdateThreadDoesNotIncrementUnreadCountForOutboundActivity(t *testing.T) {
var captured repositories.MessageThreadActivityUpdate
repository := &messageThreadRepositoryStub{
loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) {
- return &entities.MessageThread{ID: uuid.New(), IsRead: false}, nil
+ return &entities.MessageThread{ID: uuid.New(), UnreadCount: 2}, nil
},
updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error {
captured = params
@@ -134,14 +134,14 @@ func TestUpdateThreadPreservesReadStateForOutboundActivity(t *testing.T) {
assert.False(t, captured.MarkAsUnread)
}
-func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) {
+func TestCreateThreadSetsUnreadCountFromActivityDirection(t *testing.T) {
tests := []struct {
name string
marksUnread bool
- wantRead bool
+ wantUnread uint
}{
- {name: "inbound", marksUnread: true, wantRead: false},
- {name: "outbound", marksUnread: false, wantRead: true},
+ {name: "inbound", marksUnread: true, wantUnread: 1},
+ {name: "outbound", marksUnread: false, wantUnread: 0},
}
for _, test := range tests {
@@ -171,20 +171,19 @@ func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored)
- assert.Equal(t, test.wantRead, stored.IsRead)
- assert.False(t, stored.LastReadAt.IsZero())
+ assert.Equal(t, test.wantUnread, stored.UnreadCount)
})
}
}
func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) {
threadID := uuid.New()
- isRead := false
+ unreadCount := uint(0)
var captured repositories.MessageThreadStatusUpdate
repository := &messageThreadRepositoryStub{
updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) {
captured = params
- return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: false}, nil
+ return &entities.MessageThread{ID: threadID, IsArchived: true, UnreadCount: 0}, nil
},
}
@@ -192,15 +191,14 @@ func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) {
thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{
UserID: entities.UserID("user-id"),
MessageThreadID: threadID,
- IsRead: &isRead,
+ UnreadCount: &unreadCount,
})
require.NoError(t, err)
assert.Nil(t, captured.IsArchived)
- assert.Same(t, &isRead, captured.IsRead)
- assert.False(t, captured.ReadAt.IsZero())
+ assert.Same(t, &unreadCount, captured.UnreadCount)
assert.True(t, thread.IsArchived)
- assert.False(t, thread.IsRead)
+ assert.Zero(t, thread.UnreadCount)
}
func TestUpdateStatusPreservesNotFoundCode(t *testing.T) {
@@ -211,11 +209,11 @@ func TestUpdateStatusPreservesNotFoundCode(t *testing.T) {
}
service := newMessageThreadServiceForTest(repository)
- isRead := true
+ unreadCount := uint(0)
_, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{
UserID: entities.UserID("user-id"),
MessageThreadID: uuid.New(),
- IsRead: &isRead,
+ UnreadCount: &unreadCount,
})
assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err))
diff --git a/api/pkg/validators/message_thread_handler_validator.go b/api/pkg/validators/message_thread_handler_validator.go
index 72a64194..d4f08002 100644
--- a/api/pkg/validators/message_thread_handler_validator.go
+++ b/api/pkg/validators/message_thread_handler_validator.go
@@ -73,11 +73,11 @@ func (validator *MessageThreadHandlerValidator) ValidateUpdate(_ context.Context
})
errors := v.ValidateStruct()
- if request.IsArchived == nil && request.IsRead == nil {
+ if request.IsArchived == nil && request.UnreadCount == nil {
if errors == nil {
errors = url.Values{}
}
- errors.Add("payload", "at least one of is_archived or is_read is required")
+ errors.Add("payload", "at least one of is_archived or unread_count is required")
}
return errors
diff --git a/api/pkg/validators/message_thread_handler_validator_test.go b/api/pkg/validators/message_thread_handler_validator_test.go
index e5ec7c1b..4f745366 100644
--- a/api/pkg/validators/message_thread_handler_validator_test.go
+++ b/api/pkg/validators/message_thread_handler_validator_test.go
@@ -20,12 +20,12 @@ func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) {
assert.NotEmpty(t, errors.Get("payload"))
}
-func TestValidateUpdateAcceptsReadOnlyUpdate(t *testing.T) {
+func TestValidateUpdateAcceptsUnreadCountOnlyUpdate(t *testing.T) {
validator := &MessageThreadHandlerValidator{}
- isRead := true
+ unreadCount := uint(0)
request := requests.MessageThreadUpdate{
MessageThreadID: uuid.NewString(),
- IsRead: &isRead,
+ UnreadCount: &unreadCount,
}
errors := validator.ValidateUpdate(context.Background(), request)
diff --git a/tests/README.md b/tests/README.md
index 20e4f5e1..df91dee2 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -53,7 +53,7 @@ The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect al
- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status
- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint
-- [x] **Message thread read receipts E2E** — Incoming SMS and missed calls mark a thread unread, the existing thread update endpoint marks it read, and outbound activity preserves unread state
+- [x] **Message thread unread count E2E** — Incoming SMS and missed calls increment the unread count, the existing thread update endpoint clears it, and outbound activity preserves the count
- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled
- [x] **Contacts E2E** — JSON CRUD, search and pagination totals, CSV import normalization, and contact details attached to message threads
diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go
index a65e7eb7..178c720d 100644
--- a/tests/read_receipts_test.go
+++ b/tests/read_receipts_test.go
@@ -19,7 +19,7 @@ import (
type integrationMessageThread struct {
ID string `json:"id"`
Contact string `json:"contact"`
- IsRead bool `json:"is_read"`
+ UnreadCount uint `json:"unread_count"`
LastMessageContent *string `json:"last_message_content"`
}
@@ -109,7 +109,7 @@ func waitForMessageThread(
return integrationMessageThread{}
}
-func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) integrationMessageThread {
+func clearMessageThreadUnreadCount(ctx context.Context, t *testing.T, threadID string) integrationMessageThread {
t.Helper()
var response struct {
@@ -121,14 +121,14 @@ func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) i
http.MethodPut,
"/v1/message-threads/"+threadID,
userAPIKey,
- map[string]any{"is_read": true},
+ map[string]any{"unread_count": 0},
http.StatusOK,
&response,
)
return response.Data
}
-func TestMessageThreadReadReceipts(t *testing.T) {
+func TestMessageThreadUnreadCount(t *testing.T) {
ctx := context.Background()
phone := setupPhone(ctx, t, 60)
contact := randomPhoneNumber()
@@ -152,17 +152,17 @@ func TestMessageThreadReadReceipts(t *testing.T) {
)
thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool {
- return !thread.IsRead
+ return thread.UnreadCount == 1
})
- assert.False(t, thread.IsRead)
+ assert.Equal(t, uint(1), thread.UnreadCount)
- updated := markMessageThreadRead(ctx, t, thread.ID)
- assert.True(t, updated.IsRead)
+ updated := clearMessageThreadUnreadCount(ctx, t, thread.ID)
+ assert.Zero(t, updated.UnreadCount)
assert.Equal(t, contact, updated.Contact)
require.NotNil(t, updated.LastMessageContent)
assert.Equal(t, "Unread inbound message", *updated.LastMessageContent)
waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool {
- return thread.IsRead
+ return thread.UnreadCount == 0
})
requestJSON(
@@ -182,11 +182,11 @@ func TestMessageThreadReadReceipts(t *testing.T) {
)
thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool {
- return !thread.IsRead &&
+ return thread.UnreadCount == 1 &&
thread.LastMessageContent != nil &&
*thread.LastMessageContent == "Missed phone call"
})
- assert.False(t, thread.IsRead)
+ assert.Equal(t, uint(1), thread.UnreadCount)
outboundContent := "Outbound activity preserves unread"
client := newAPIClient()
@@ -202,5 +202,5 @@ func TestMessageThreadReadReceipts(t *testing.T) {
return thread.LastMessageContent != nil &&
*thread.LastMessageContent == outboundContent
})
- assert.False(t, thread.IsRead, "outbound activity must not clear unread state")
+ assert.Equal(t, uint(1), thread.UnreadCount, "outbound activity must not clear unread count")
}
diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue
index 51de93fc..93c77324 100644
--- a/web/app/components/MessageThread.vue
+++ b/web/app/components/MessageThread.vue
@@ -22,6 +22,22 @@ function threadDate(date: string): string {
})
}
+function hasUnreadMessages(unreadCount: number): boolean {
+ return unreadCount > 0
+}
+
+function unreadBadge(
+ unreadCount: number,
+): false | { color: string; content: string; dot: false } {
+ if (unreadCount === 0) return false
+
+ return {
+ color: 'primary',
+ content: unreadCount > 99 ? '99+' : String(unreadCount),
+ dot: false,
+ }
+}
+
function onInstallApp() {
notificationsStore.addNotification({
type: 'info',
@@ -118,7 +134,7 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string {
{{
mdiAccount
@@ -128,12 +144,20 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string {
}}
- {{
- threadContactTitle(thread)
- }}
+
+ {{ threadContactTitle(thread) }}
+
{{ thread.last_message_content }}
diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue
index f933973d..87f4e51e 100644
--- a/web/app/pages/threads/[id]/index.vue
+++ b/web/app/pages/threads/[id]/index.vue
@@ -128,19 +128,36 @@ function scrollToElement() {
hideMessages.value = false
}
-async function markCurrentThreadRead(force = false) {
+async function resetCurrentThreadUnreadCount(force = false) {
const threadId = route.params.id as string
try {
- await threadsStore.markThreadRead(threadId, force)
+ await threadsStore.resetThreadUnreadCount(threadId, force)
} catch (error) {
console.error(error)
}
}
-function loadMessages(hide = true, markRead = true) {
+async function handleInboundMessage() {
+ if (loadingMessages.value) return
+
+ try {
+ const thread = currentThread.value
+ await threadsStore.loadThreads()
+ if (!thread || loadingMessages.value) {
+ return
+ }
+
+ await resetCurrentThreadUnreadCount(true)
+ loadMessages(false, false)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+function loadMessages(hide = true, resetUnreadCount = true) {
loadingMessages.value = true
const threadId = route.params.id as string
- if (markRead) void markCurrentThreadRead()
+ if (resetUnreadCount) void resetCurrentThreadUnreadCount()
threadsStore
.loadThreadMessages(threadId)
.then((response: EntitiesMessage[]) => {
@@ -257,17 +274,11 @@ onMounted(async () => {
webhookChannel.bind('message.send.failed', () => {
if (!loadingMessages.value) loadMessages(false)
})
- webhookChannel.bind('message.phone.received', () => {
- if (!loadingMessages.value) {
- void markCurrentThreadRead(true)
- loadMessages(false, false)
- }
+ webhookChannel.bind('message.phone.received', (_: never) => {
+ void handleInboundMessage()
})
- webhookChannel.bind('message.call.missed', () => {
- if (!loadingMessages.value) {
- void markCurrentThreadRead(true)
- loadMessages(false, false)
- }
+ webhookChannel.bind('message.call.missed', (_: never) => {
+ void handleInboundMessage()
})
})
diff --git a/web/app/stores/messages.ts b/web/app/stores/messages.ts
index abaf47b6..ae0df175 100644
--- a/web/app/stores/messages.ts
+++ b/web/app/stores/messages.ts
@@ -48,6 +48,13 @@ export const useMessagesStore = defineStore('messages', () => {
})
}
+ async function getMessage(messageId: string): Promise {
+ const response = await apiFetch<{ data: EntitiesMessage }>(
+ `/v1/messages/${messageId}`,
+ )
+ return response.data
+ }
+
async function searchMessages(
payload: SearchMessagesRequest,
): Promise {
@@ -88,6 +95,7 @@ export const useMessagesStore = defineStore('messages', () => {
return {
sendMessage,
deleteMessage,
+ getMessage,
searchMessages,
sendBulkMessages,
fetchBulkMessageOrders,
diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts
index fdc7094e..1abd68be 100644
--- a/web/app/stores/threads.ts
+++ b/web/app/stores/threads.ts
@@ -29,7 +29,7 @@ export const useThreadsStore = defineStore('threads', () => {
(thread) => thread.id === updatedThread.id,
)
if (index !== -1) {
- const existingThread = threads.value[index]
+ const existingThread = threads.value[index]!
threads.value[index] = {
...updatedThread,
// Mark-read PUT responses are not contact-enriched; keep the resolved
@@ -113,17 +113,17 @@ export const useThreadsStore = defineStore('threads', () => {
})
}
- async function markThreadRead(threadId: string, force = false) {
+ async function resetThreadUnreadCount(threadId: string, force = false) {
const thread = threads.value.find((item) => item.id === threadId)
if (!thread) throw new Error(`Cannot find thread with id ${threadId}`)
- if (!force && thread.is_read) return
+ if (!force && thread.unread_count === 0) return
try {
const response = await apiFetch<{ data: EntitiesMessageThread }>(
`/v1/message-threads/${threadId}`,
{
method: 'PUT',
- body: { is_read: true },
+ body: { unread_count: 0 },
},
)
replaceThread(response.data)
@@ -174,7 +174,7 @@ export const useThreadsStore = defineStore('threads', () => {
setThreadId,
toggleArchive,
updateThread,
- markThreadRead,
+ resetThreadUnreadCount,
deleteThread,
resetState,
}
diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts
index 3f85c4fb..becb2063 100644
--- a/web/shared/types/api.ts
+++ b/web/shared/types/api.ts
@@ -225,8 +225,6 @@ export interface EntitiesMessageThread {
id: string;
/** @example false */
is_archived: boolean;
- /** @example true */
- is_read: boolean;
/** @example "This is a sample message content" */
last_message_content: string;
/** @example "32343a19-da5e-4b1b-a767-3298a73703ca" */
@@ -237,6 +235,8 @@ export interface EntitiesMessageThread {
owner: string;
/** @example "PENDING" */
status: string;
+ /** @example 0 */
+ unread_count: number;
/** @example "2022-06-05T14:26:09.527976+03:00" */
updated_at: string;
/** @example "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" */
@@ -527,8 +527,8 @@ export interface RequestsMessageSendScheduleWindow {
export interface RequestsMessageThreadUpdate {
/** @example true */
is_archived?: boolean;
- /** @example true */
- is_read?: boolean;
+ /** @example 0 */
+ unread_count?: number;
}
export interface RequestsPhoneAPIKeyStoreRequest {