Skip to content

Commit c2d357c

Browse files
AchoArnoldCopilotCopilot
authored
feat(api): track unread thread count (NdoleStudio#978)
* feat(api)!: track unread thread count Replace the boolean read state with an unread message counter so repeated inbound activity remains visible until the thread is explicitly cleared. BREAKING CHANGE: Message thread responses and updates use unread_count instead of is_read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8efee0ef-a8be-4d9c-b4ec-33dcbf0dfdfd * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: missed phone call string and webhook refresh * fix(api): add thread conflict index Create the composite unique index required by the message-thread upsert so fresh databases can execute its ON CONFLICT target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8efee0ef-a8be-4d9c-b4ec-33dcbf0dfdfd --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: 8efee0ef-a8be-4d9c-b4ec-33dcbf0dfdfd
1 parent 85c2167 commit c2d357c

22 files changed

Lines changed: 200 additions & 170 deletions

‎api/docs/docs.go‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4129,12 +4129,12 @@ const docTemplate = `{
41294129
"created_at",
41304130
"id",
41314131
"is_archived",
4132-
"is_read",
41334132
"last_message_content",
41344133
"last_message_id",
41354134
"order_timestamp",
41364135
"owner",
41374136
"status",
4137+
"unread_count",
41384138
"updated_at",
41394139
"user_id"
41404140
],
@@ -4167,10 +4167,6 @@ const docTemplate = `{
41674167
"type": "boolean",
41684168
"example": false
41694169
},
4170-
"is_read": {
4171-
"type": "boolean",
4172-
"example": true
4173-
},
41744170
"last_message_content": {
41754171
"type": "string",
41764172
"example": "This is a sample message content"
@@ -4191,6 +4187,10 @@ const docTemplate = `{
41914187
"type": "string",
41924188
"example": "PENDING"
41934189
},
4190+
"unread_count": {
4191+
"type": "integer",
4192+
"example": 0
4193+
},
41944194
"updated_at": {
41954195
"type": "string",
41964196
"example": "2022-06-05T14:26:09.527976+03:00"
@@ -4920,9 +4920,9 @@ const docTemplate = `{
49204920
"type": "boolean",
49214921
"example": true
49224922
},
4923-
"is_read": {
4924-
"type": "boolean",
4925-
"example": true
4923+
"unread_count": {
4924+
"type": "integer",
4925+
"example": 0
49264926
}
49274927
}
49284928
},

‎api/docs/swagger.json‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4126,12 +4126,12 @@
41264126
"created_at",
41274127
"id",
41284128
"is_archived",
4129-
"is_read",
41304129
"last_message_content",
41314130
"last_message_id",
41324131
"order_timestamp",
41334132
"owner",
41344133
"status",
4134+
"unread_count",
41354135
"updated_at",
41364136
"user_id"
41374137
],
@@ -4164,10 +4164,6 @@
41644164
"type": "boolean",
41654165
"example": false
41664166
},
4167-
"is_read": {
4168-
"type": "boolean",
4169-
"example": true
4170-
},
41714167
"last_message_content": {
41724168
"type": "string",
41734169
"example": "This is a sample message content"
@@ -4188,6 +4184,10 @@
41884184
"type": "string",
41894185
"example": "PENDING"
41904186
},
4187+
"unread_count": {
4188+
"type": "integer",
4189+
"example": 0
4190+
},
41914191
"updated_at": {
41924192
"type": "string",
41934193
"example": "2022-06-05T14:26:09.527976+03:00"
@@ -4917,9 +4917,9 @@
49174917
"type": "boolean",
49184918
"example": true
49194919
},
4920-
"is_read": {
4921-
"type": "boolean",
4922-
"example": true
4920+
"unread_count": {
4921+
"type": "integer",
4922+
"example": 0
49234923
}
49244924
}
49254925
},

‎api/docs/swagger.yaml‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,6 @@ definitions:
367367
is_archived:
368368
example: false
369369
type: boolean
370-
is_read:
371-
example: true
372-
type: boolean
373370
last_message_content:
374371
example: This is a sample message content
375372
type: string
@@ -385,6 +382,9 @@ definitions:
385382
status:
386383
example: PENDING
387384
type: string
385+
unread_count:
386+
example: 0
387+
type: integer
388388
updated_at:
389389
example: "2022-06-05T14:26:09.527976+03:00"
390390
type: string
@@ -397,12 +397,12 @@ definitions:
397397
- created_at
398398
- id
399399
- is_archived
400-
- is_read
401400
- last_message_content
402401
- last_message_id
403402
- order_timestamp
404403
- owner
405404
- status
405+
- unread_count
406406
- updated_at
407407
- user_id
408408
type: object
@@ -956,9 +956,9 @@ definitions:
956956
is_archived:
957957
example: true
958958
type: boolean
959-
is_read:
960-
example: true
961-
type: boolean
959+
unread_count:
960+
example: 0
961+
type: integer
962962
type: object
963963
requests.PhoneAPIKeyStoreRequest:
964964
properties:

‎api/pkg/entities/message_thread.go‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ import (
99
// MessageThread represents a message thread between 2 phone numbers
1010
type MessageThread struct {
1111
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703ca"`
12-
Owner string `json:"owner" example:"+18005550199"`
13-
Contact string `json:"contact" example:"+18005550100"`
12+
Owner string `json:"owner" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:2" example:"+18005550199"`
13+
Contact string `json:"contact" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:3" example:"+18005550100"`
1414
IsArchived bool `json:"is_archived" example:"false"`
15-
IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"`
16-
LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"`
17-
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
15+
UnreadCount uint `json:"unread_count" gorm:"not null;default:0" example:"0"`
16+
UserID UserID `json:"user_id" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:1" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
1817
Color string `json:"color" example:"indigo"`
1918
Status MessageStatus `json:"status" example:"PENDING"`
2019
LastMessageContent *string `json:"last_message_content" example:"This is a sample message content"`

‎api/pkg/entities/message_thread_test.go‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,29 @@ import (
99
"github.com/stretchr/testify/require"
1010
)
1111

12-
func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) {
12+
func TestMessageThreadUnreadCountHasDefault(t *testing.T) {
1313
threadType := reflect.TypeOf(MessageThread{})
1414

15-
isRead, ok := threadType.FieldByName("IsRead")
15+
unreadCount, ok := threadType.FieldByName("UnreadCount")
1616
require.True(t, ok)
17-
assert.Contains(t, isRead.Tag.Get("gorm"), "not null")
18-
assert.Contains(t, isRead.Tag.Get("gorm"), "default:true")
19-
assert.Equal(t, "is_read", isRead.Tag.Get("json"))
17+
assert.Contains(t, unreadCount.Tag.Get("gorm"), "not null")
18+
assert.Contains(t, unreadCount.Tag.Get("gorm"), "default:0")
19+
assert.Equal(t, "unread_count", unreadCount.Tag.Get("json"))
20+
}
2021

21-
lastReadAt, ok := threadType.FieldByName("LastReadAt")
22-
require.True(t, ok)
23-
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null")
24-
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP")
25-
assert.Equal(t, "-", lastReadAt.Tag.Get("json"))
22+
func TestMessageThreadHasUniqueOwnerContactPerUser(t *testing.T) {
23+
threadType := reflect.TypeOf(MessageThread{})
24+
25+
expectedTags := map[string]string{
26+
"UserID": "uniqueIndex:idx_message_threads_user_owner_contact,priority:1",
27+
"Owner": "uniqueIndex:idx_message_threads_user_owner_contact,priority:2",
28+
"Contact": "uniqueIndex:idx_message_threads_user_owner_contact,priority:3",
29+
}
30+
for fieldName, expectedTag := range expectedTags {
31+
field, ok := threadType.FieldByName(fieldName)
32+
require.True(t, ok)
33+
assert.Contains(t, field.Tag.Get("gorm"), expectedTag)
34+
}
2635
}
2736

2837
func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) {

‎api/pkg/handlers/message_thread_handler_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin
7575
handler.RegisterRoutes(app)
7676

7777
messageThreadID := uuid.New()
78-
req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"is_read":true}`))
78+
req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"unread_count":0}`))
7979
req.Header.Set("Content-Type", "application/json")
8080

8181
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})

‎api/pkg/repositories/gorm_message_thread_repository.go‎

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,7 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string
4646
updates["is_archived"] = false
4747
}
4848
if params.MarkAsUnread {
49-
updates["is_read"] = gorm.Expr(
50-
"CASE WHEN last_read_at < ? THEN ? ELSE is_read END",
51-
params.EventTimestamp,
52-
false,
53-
)
49+
updates["unread_count"] = gorm.Expr("unread_count + ?", 1)
5450
}
5551
return updates
5652
}
@@ -68,11 +64,8 @@ func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any
6864
if params.IsArchived != nil {
6965
updates["is_archived"] = *params.IsArchived
7066
}
71-
if params.IsRead != nil {
72-
updates["is_read"] = *params.IsRead
73-
if *params.IsRead {
74-
updates["last_read_at"] = params.ReadAt
75-
}
67+
if params.UnreadCount != nil {
68+
updates["unread_count"] = *params.UnreadCount
7669
}
7770
return updates
7871
}
@@ -123,27 +116,24 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread
123116
ctx, span := repository.tracer.Start(ctx)
124117
defer span.End()
125118

126-
isRead := thread.IsRead
127-
err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
128-
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(thread)
129-
thread.IsRead = isRead
130-
if result.Error != nil {
131-
return result.Error
132-
}
133-
if result.RowsAffected == 0 || isRead {
134-
return nil
135-
}
136-
137-
return tx.Model(&entities.MessageThread{}).
138-
Where("user_id = ?", thread.UserID).
139-
Where("id = ?", thread.ID).
140-
UpdateColumn("is_read", false).
141-
Error
142-
})
143-
if err != nil {
144-
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save message thread with ID [%s]", thread.ID))
119+
db := repository.db.WithContext(ctx).Session(&gorm.Session{SkipDefaultTransaction: true})
120+
onConflict := clause.OnConflict{
121+
Columns: []clause.Column{
122+
{Name: "user_id"},
123+
{Name: "owner"},
124+
{Name: "contact"},
125+
},
126+
DoNothing: thread.UnreadCount == 0,
127+
}
128+
if thread.UnreadCount > 0 {
129+
onConflict.DoUpdates = clause.Assignments(map[string]any{
130+
"unread_count": gorm.Expr("unread_count + ?", 1),
131+
})
145132
}
146133

134+
if result := db.Clauses(onConflict).Create(thread); result.Error != nil {
135+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot insert message thread with ID [%s]", thread.ID))
136+
}
147137
return nil
148138
}
149139

‎api/pkg/repositories/gorm_message_thread_repository_test.go‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func (logger *messageThreadTestLogger) Debug(string)
7575
func (logger *messageThreadTestLogger) Fatal(error) {}
7676
func (logger *messageThreadTestLogger) Printf(string, ...interface{}) {}
7777

78-
func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) {
78+
func TestMessageThreadStoreIncrementsUnreadCountOnConflict(t *testing.T) {
7979
pool := &messageThreadTestConnPool{}
8080
db, err := gorm.Open(
8181
postgres.New(postgres.Config{
@@ -89,18 +89,17 @@ func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) {
8989
logger := &messageThreadTestLogger{}
9090
repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db)
9191
thread := &entities.MessageThread{
92-
ID: uuid.New(),
93-
IsRead: false,
92+
ID: uuid.New(),
93+
UnreadCount: 1,
9494
}
9595

9696
require.NoError(t, repository.Store(context.Background(), thread))
97-
assert.False(t, thread.IsRead)
97+
assert.Equal(t, uint(1), thread.UnreadCount)
9898

9999
require.NotEmpty(t, pool.statements)
100-
update := pool.statements[len(pool.statements)-1]
101-
assert.True(t, strings.HasPrefix(update.query, `UPDATE "message_threads"`))
102-
assert.Contains(t, update.query, `"is_read"=$1`)
103-
assert.Contains(t, update.args, false)
100+
insert := pool.statements[len(pool.statements)-1]
101+
assert.True(t, strings.HasPrefix(insert.query, `INSERT INTO "message_threads"`))
102+
assert.Contains(t, insert.query, `ON CONFLICT ("user_id","owner","contact") DO UPDATE SET "unread_count"=unread_count + $`)
104103
}
105104

106105
func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) {
@@ -118,9 +117,8 @@ func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) {
118117
"last_message_content": "hello",
119118
"status": entities.MessageStatus(entities.MessageStatusReceived),
120119
}, updates)
121-
assert.NotContains(t, updates, "is_read")
120+
assert.NotContains(t, updates, "unread_count")
122121
assert.NotContains(t, updates, "is_archived")
123-
assert.NotContains(t, updates, "last_read_at")
124122
}
125123

126124
func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) {
@@ -156,7 +154,7 @@ func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) {
156154
}
157155
}
158156
require.Len(t, updates, 1)
159-
assert.Contains(t, updates[0].query, `"is_read"=CASE WHEN last_read_at <`)
157+
assert.Contains(t, updates[0].query, `"unread_count"=unread_count + $`)
160158
}
161159

162160
func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) {
@@ -175,19 +173,14 @@ func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) {
175173
}, updates)
176174
}
177175

178-
func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) {
179-
isRead := true
180-
readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC)
176+
func TestMessageThreadStatusUpdatesUnreadCountOnly(t *testing.T) {
177+
unreadCount := uint(0)
181178

182179
updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{
183-
IsRead: &isRead,
184-
ReadAt: readAt,
180+
UnreadCount: &unreadCount,
185181
})
186182

187-
assert.Equal(t, map[string]any{
188-
"is_read": true,
189-
"last_read_at": readAt,
190-
}, updates)
183+
assert.Equal(t, map[string]any{"unread_count": uint(0)}, updates)
191184
assert.NotContains(t, updates, "is_archived")
192185
}
193186

@@ -197,8 +190,6 @@ func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) {
197190
updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{
198191
IsArchived: &isArchived,
199192
})
200-
201193
assert.Equal(t, map[string]any{"is_archived": true}, updates)
202-
assert.NotContains(t, updates, "is_read")
203-
assert.NotContains(t, updates, "last_read_at")
194+
assert.NotContains(t, updates, "unread_count")
204195
}

‎api/pkg/repositories/message_thread_repository.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ type MessageThreadActivityUpdate struct {
2323
}
2424

2525
type MessageThreadStatusUpdate struct {
26-
IsArchived *bool
27-
IsRead *bool
28-
ReadAt time.Time
26+
IsArchived *bool
27+
UnreadCount *uint
2928
}
3029

3130
type MessageThreadDeletedUpdate struct {

0 commit comments

Comments
 (0)