Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 8 additions & 8 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -4920,9 +4920,9 @@ const docTemplate = `{
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
"unread_count": {
"type": "integer",
"example": 0
}
}
},
Expand Down
16 changes: 8 additions & 8 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -4917,9 +4917,9 @@
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
"unread_count": {
"type": "integer",
"example": 0
}
}
},
Expand Down
14 changes: 7 additions & 7 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 4 additions & 5 deletions api/pkg/entities/message_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
29 changes: 19 additions & 10 deletions api/pkg/entities/message_thread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion api/pkg/handlers/message_thread_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
48 changes: 19 additions & 29 deletions api/pkg/repositories/gorm_message_thread_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
AchoArnold marked this conversation as resolved.
}
return updates
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down
37 changes: 14 additions & 23 deletions api/pkg/repositories/gorm_message_thread_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
}

Expand All @@ -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")
}
5 changes: 2 additions & 3 deletions api/pkg/repositories/message_thread_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ type MessageThreadActivityUpdate struct {
}

type MessageThreadStatusUpdate struct {
IsArchived *bool
IsRead *bool
ReadAt time.Time
IsArchived *bool
UnreadCount *uint
}

type MessageThreadDeletedUpdate struct {
Expand Down
Loading
Loading