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 {