Skip to content

Commit 96abdba

Browse files
AchoArnoldCopilot
andcommitted
feat: add thread archiving and unread filters
Add per-phone outgoing archiving and server-side unread filtering. Include integration coverage, frontend type fixes, and login badge fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b
1 parent b746b11 commit 96abdba

45 files changed

Lines changed: 1633 additions & 642 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎api/docs/docs.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,13 @@ const docTemplate = `{
11661166
"description": "include matching contact details",
11671167
"name": "contacts",
11681168
"in": "query"
1169+
},
1170+
{
1171+
"type": "boolean",
1172+
"default": false,
1173+
"description": "only include threads with unread messages",
1174+
"name": "unread_only",
1175+
"in": "query"
11691176
}
11701177
],
11711178
"responses": {
@@ -4204,6 +4211,7 @@ const docTemplate = `{
42044211
"entities.Phone": {
42054212
"type": "object",
42064213
"required": [
4214+
"archive_new_outgoing_message_threads",
42074215
"created_at",
42084216
"id",
42094217
"max_send_attempts",
@@ -4216,6 +4224,11 @@ const docTemplate = `{
42164224
"user_id"
42174225
],
42184226
"properties": {
4227+
"archive_new_outgoing_message_threads": {
4228+
"description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.",
4229+
"type": "boolean",
4230+
"example": false
4231+
},
42194232
"created_at": {
42204233
"type": "string",
42214234
"example": "2022-06-05T14:26:02.302718+03:00"
@@ -4975,6 +4988,11 @@ const docTemplate = `{
49754988
"unarchive_thread"
49764989
],
49774990
"properties": {
4991+
"archive_new_outgoing_message_threads": {
4992+
"description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.",
4993+
"type": "boolean",
4994+
"example": false
4995+
},
49784996
"fcm_token": {
49794997
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49804998
"type": "string",

‎api/docs/swagger.json‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,13 @@
11631163
"description": "include matching contact details",
11641164
"name": "contacts",
11651165
"in": "query"
1166+
},
1167+
{
1168+
"type": "boolean",
1169+
"default": false,
1170+
"description": "only include threads with unread messages",
1171+
"name": "unread_only",
1172+
"in": "query"
11661173
}
11671174
],
11681175
"responses": {
@@ -4201,6 +4208,7 @@
42014208
"entities.Phone": {
42024209
"type": "object",
42034210
"required": [
4211+
"archive_new_outgoing_message_threads",
42044212
"created_at",
42054213
"id",
42064214
"max_send_attempts",
@@ -4213,6 +4221,11 @@
42134221
"user_id"
42144222
],
42154223
"properties": {
4224+
"archive_new_outgoing_message_threads": {
4225+
"description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.",
4226+
"type": "boolean",
4227+
"example": false
4228+
},
42164229
"created_at": {
42174230
"type": "string",
42184231
"example": "2022-06-05T14:26:02.302718+03:00"
@@ -4972,6 +4985,11 @@
49724985
"unarchive_thread"
49734986
],
49744987
"properties": {
4988+
"archive_new_outgoing_message_threads": {
4989+
"description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.",
4990+
"type": "boolean",
4991+
"example": false
4992+
},
49754993
"fcm_token": {
49764994
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49774995
"type": "string",

‎api/docs/swagger.yaml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,11 @@ definitions:
408408
type: object
409409
entities.Phone:
410410
properties:
411+
archive_new_outgoing_message_threads:
412+
description: ArchiveNewOutgoingMessageThreads archives new conversations started
413+
by an outgoing message on this phone.
414+
example: false
415+
type: boolean
411416
created_at:
412417
example: "2022-06-05T14:26:02.302718+03:00"
413418
type: string
@@ -452,6 +457,7 @@ definitions:
452457
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
453458
type: string
454459
required:
460+
- archive_new_outgoing_message_threads
455461
- created_at
456462
- id
457463
- max_send_attempts
@@ -990,6 +996,11 @@ definitions:
990996
type: object
991997
requests.PhoneUpsert:
992998
properties:
999+
archive_new_outgoing_message_threads:
1000+
description: ArchiveNewOutgoingMessageThreads archives new conversations started
1001+
by an outgoing message on this phone.
1002+
example: false
1003+
type: boolean
9931004
fcm_token:
9941005
description: FcmToken is either a Firebase registration token or a public
9951006
HTTPS adapter callback URL.
@@ -2510,6 +2521,11 @@ paths:
25102521
in: query
25112522
name: contacts
25122523
type: boolean
2524+
- default: false
2525+
description: only include threads with unread messages
2526+
in: query
2527+
name: unread_only
2528+
type: boolean
25132529
produces:
25142530
- application/json
25152531
responses:

‎api/pkg/entities/phone.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ type Phone struct {
3030
// UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.
3131
UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"`
3232

33+
// ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.
34+
ArchiveNewOutgoingMessageThreads bool `json:"archive_new_outgoing_message_threads" gorm:"default:false" example:"false"`
35+
3336
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
3437
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
3538
}

‎api/pkg/handlers/message_thread_handler.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares .
5858
// @Param query query string false "filter message threads containing query"
5959
// @Param limit query int false "number of messages to return" minimum(1) maximum(20)
6060
// @Param contacts query bool false "include matching contact details"
61+
// @Param unread_only query bool false "only include threads with unread messages" default(false)
6162
// @Success 200 {object} responses.MessageThreadsResponse
6263
// @Failure 400 {object} responses.BadRequest
6364
// @Failure 401 {object} responses.Unauthorized

‎api/pkg/handlers/message_thread_handler_contacts_test.go‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import (
2222

2323
type messageThreadHandlerIndexRepositoryStub struct {
2424
repositories.MessageThreadRepository
25-
threads []entities.MessageThread
25+
threads []entities.MessageThread
26+
unreadOnly bool
2627
}
2728

28-
func (stub *messageThreadHandlerIndexRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) {
29+
func (stub *messageThreadHandlerIndexRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, unreadOnly bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) {
30+
stub.unreadOnly = unreadOnly
2931
threads := make([]entities.MessageThread, len(stub.threads))
3032
copy(threads, stub.threads)
3133
return &threads, nil
@@ -41,7 +43,7 @@ func (stub *messageThreadHandlerContactProviderStub) GetContactMap(context.Conte
4143
return stub.contacts, nil
4244
}
4345

44-
func TestMessageThreadHandlerIndex_ParsesContactsQuery(t *testing.T) {
46+
func TestMessageThreadHandlerIndex_ParsesContactsAndUnreadQuery(t *testing.T) {
4547
contact := &entities.Contact{ID: uuid.New(), Name: "Alice", PhoneNumbers: []string{"+18005550100"}}
4648
repository := &messageThreadHandlerIndexRepositoryStub{threads: []entities.MessageThread{{Contact: "+18005550100"}}}
4749
provider := &messageThreadHandlerContactProviderStub{contacts: map[string]*entities.Contact{"+18005550100": contact}}
@@ -57,19 +59,41 @@ func TestMessageThreadHandlerIndex_ParsesContactsQuery(t *testing.T) {
5759
})
5860
handler.RegisterRoutes(app)
5961

60-
req := httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&contacts=true", nil)
62+
req := httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&contacts=true&unread_only=true", nil)
6163
resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
6264

6365
require.NoError(t, err)
6466
require.Equal(t, http.StatusOK, resp.StatusCode)
6567
assert.Equal(t, 1, provider.calls)
68+
assert.True(t, repository.unreadOnly)
6669

6770
var payload struct {
6871
Data []entities.MessageThread `json:"data"`
6972
}
73+
7074
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
7175
require.Len(t, payload.Data, 1)
7276
require.NotNil(t, payload.Data[0].ContactDetails)
7377
assert.Equal(t, contact.ID, payload.Data[0].ContactDetails.ID)
7478
assert.Equal(t, "Alice", payload.Data[0].ContactDetails.Name)
7579
}
80+
81+
func TestMessageThreadHandlerIndexRejectsInvalidUnreadFilter(t *testing.T) {
82+
repository := &messageThreadHandlerIndexRepositoryStub{}
83+
logger := &messageThreadHandlerNoopLogger{}
84+
tracer := telemetry.NewOtelLogger("test", logger)
85+
service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, nil)
86+
handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service)
87+
app := fiber.New()
88+
app.Use(func(c fiber.Ctx) error {
89+
c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"})
90+
return c.Next()
91+
})
92+
handler.RegisterRoutes(app)
93+
94+
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&unread_only=invalid", nil))
95+
96+
require.NoError(t, err)
97+
defer response.Body.Close()
98+
assert.Equal(t, http.StatusUnprocessableEntity, response.StatusCode)
99+
}

‎api/pkg/handlers/message_thread_handler_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (stub *messageThreadHandlerRepositoryStub) Load(context.Context, entities.U
4545
return nil, nil
4646
}
4747

48-
func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) {
48+
func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, bool, repositories.IndexParams) (*[]entities.MessageThread, error) {
4949
return nil, nil
5050
}
5151

‎api/pkg/listeners/read_receipts_test_helpers_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (repository *listenerMessageThreadRepository) Load(context.Context, entitie
5252
return &entities.MessageThread{}, nil
5353
}
5454

55-
func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) {
55+
func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, bool, repositories.IndexParams) (*[]entities.MessageThread, error) {
5656
threads := []entities.MessageThread{}
5757
return &threads, nil
5858
}

‎api/pkg/repositories/gorm_message_thread_repository.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ func (repository *gormMessageThreadRepository) Load(ctx context.Context, userID
234234
}
235235

236236
// Index message threads for an owner
237-
func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID entities.UserID, owner string, isArchived bool, params IndexParams) (*[]entities.MessageThread, error) {
237+
func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID entities.UserID, owner string, isArchived bool, unreadOnly bool, params IndexParams) (*[]entities.MessageThread, error) {
238238
ctx, span := repository.tracer.Start(ctx)
239239
defer span.End()
240240

@@ -249,6 +249,10 @@ func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID
249249
query.Where(repository.db.Where("is_archived = ?", isArchived).Or("is_archived IS NULL"))
250250
}
251251

252+
if unreadOnly {
253+
query.Where("unread_count > ?", 0)
254+
}
255+
252256
if len(params.Query) > 0 {
253257
queryPattern := "%" + params.Query + "%"
254258
query.Where(

‎api/pkg/repositories/gorm_message_thread_repository_test.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"database/sql/driver"
77
"errors"
8+
"fmt"
89
"strings"
910
"testing"
1011
"time"
@@ -24,6 +25,42 @@ type messageThreadTestStatement struct {
2425
args []any
2526
}
2627

28+
func TestMessageThreadIndexFiltersUnreadBeforePagination(t *testing.T) {
29+
for _, unreadOnly := range []bool{false, true} {
30+
t.Run(fmt.Sprintf("unread_only=%t", unreadOnly), func(t *testing.T) {
31+
db, err := gorm.Open(postgres.New(postgres.Config{Conn: &messageThreadTestConnPool{}}), &gorm.Config{
32+
DisableAutomaticPing: true,
33+
DryRun: true,
34+
})
35+
require.NoError(t, err)
36+
var query string
37+
var args []any
38+
require.NoError(t, db.Callback().Query().After("gorm:query").Register("capture", func(db *gorm.DB) {
39+
query = db.Statement.SQL.String()
40+
args = db.Statement.Vars
41+
}))
42+
logger := &messageThreadTestLogger{}
43+
repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db)
44+
45+
_, err = repository.Index(context.Background(), entities.UserID("user-id"), "+18005550199", true, unreadOnly, IndexParams{Limit: 20, Skip: 5})
46+
47+
require.NoError(t, err)
48+
assert.Contains(t, query, "user_id = $1")
49+
assert.Contains(t, query, "owner = $2")
50+
assert.Contains(t, query, "is_archived = $3")
51+
assert.Equal(t, []any{entities.UserID("user-id"), "+18005550199", true}, args[:3])
52+
if unreadOnly {
53+
assert.Contains(t, query, "unread_count >")
54+
assert.Less(t, strings.Index(query, "unread_count >"), strings.Index(query, "LIMIT"))
55+
} else {
56+
assert.NotContains(t, query, "unread_count >")
57+
}
58+
assert.Contains(t, query, "ORDER BY order_timestamp DESC LIMIT")
59+
assert.Contains(t, query, "OFFSET")
60+
})
61+
}
62+
}
63+
2764
type messageThreadTestConnPool struct {
2865
statements []messageThreadTestStatement
2966
}

0 commit comments

Comments
 (0)