Skip to content

Commit 8010ce3

Browse files
AchoArnoldCopilot
andauthored
feat: unarchive message thread on new inbound message (per-phone setting) (#954)
* feat(api): add UnarchiveThread setting to Phone entity * feat(api): add UnarchiveThread to MessagePhoneReceivedPayload * feat(api): populate UnarchiveThread from phone on received event * feat(api): unarchive thread on inbound message when enabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): forward UnarchiveThread flag from received event to thread update * feat(api): accept unarchive_thread in phone upsert request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(web): regenerate api models with unarchive_thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat(web): send unarchive_thread in updatePhone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat(web): add per-phone unarchive-thread toggle to settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test(integration): verify unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat: fix english * feat: fix english * fix: address PR review comments on unarchive-thread - URL-escape the E.164 owner in the message-threads query (+ decodes to space) - Use native stacktrace.Propagate printf args (avoids go vet non-constant format warning) - Document why setUnarchiveThread must send the required sim field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * perf: load unarchive flag lazily for archived threads Consult the phone's UnarchiveThread setting inside MessageThreadService.UpdateThread only when an inbound message lands on an archived thread, instead of loading the phone on every received message. Removes the per-message phone read in ReceiveMessage and drops the UnarchiveThread field from the received-message event payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test: retry inbound receive while phone api key associates The phone-to-API-key association runs asynchronously via the phone_api_key event listener, so a freshly provisioned phone can return 401 on receive until the auth cache clears. Retry receiveInbound on that transient 401 instead of failing immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test: cover not-archived non-received unarchive branch Remove an unrelated webhook email payload formatting plan doc that was accidentally included in this feature branch, and add the missing shouldCheckUnarchive case for a non-archived thread with a non-received status so every branch of the predicate is exercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f03d163 commit 8010ce3

19 files changed

Lines changed: 563 additions & 10 deletions

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ SECURITY_AUDIT_REPORT.md
1313

1414
*.exe
1515

16-
docs/
1716
.output
1817
.agents/
1918
skills-lock.json

api/docs/docs.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3379,7 +3379,7 @@ const docTemplate = `{
33793379
},
33803380
"request_id": {
33813381
"type": "string",
3382-
"example": "bulk-csv-a1B2c3D4e5"
3382+
"example": "bulk-httpsms-file.csv"
33833383
},
33843384
"scheduled_count": {
33853385
"type": "integer",
@@ -3760,6 +3760,7 @@ const docTemplate = `{
37603760
"messages_per_minute",
37613761
"phone_number",
37623762
"sim",
3763+
"unarchive_thread",
37633764
"updated_at",
37643765
"user_id"
37653766
],
@@ -3804,6 +3805,11 @@ const docTemplate = `{
38043805
"sim": {
38053806
"$ref": "#/definitions/entities.SIM"
38063807
},
3808+
"unarchive_thread": {
3809+
"description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.",
3810+
"type": "boolean",
3811+
"example": false
3812+
},
38073813
"updated_at": {
38083814
"type": "string",
38093815
"example": "2022-06-05T14:26:10.303278+03:00"
@@ -4436,7 +4442,8 @@ const docTemplate = `{
44364442
"messages_per_minute",
44374443
"missed_call_auto_reply",
44384444
"phone_number",
4439-
"sim"
4445+
"sim",
4446+
"unarchive_thread"
44404447
],
44414448
"properties": {
44424449
"fcm_token": {
@@ -4473,6 +4480,11 @@ const docTemplate = `{
44734480
"description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
44744481
"type": "string",
44754482
"example": "SIM1"
4483+
},
4484+
"unarchive_thread": {
4485+
"description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.",
4486+
"type": "boolean",
4487+
"example": false
44764488
}
44774489
}
44784490
},

api/docs/swagger.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3376,7 +3376,7 @@
33763376
},
33773377
"request_id": {
33783378
"type": "string",
3379-
"example": "bulk-csv-a1B2c3D4e5"
3379+
"example": "bulk-httpsms-file.csv"
33803380
},
33813381
"scheduled_count": {
33823382
"type": "integer",
@@ -3757,6 +3757,7 @@
37573757
"messages_per_minute",
37583758
"phone_number",
37593759
"sim",
3760+
"unarchive_thread",
37603761
"updated_at",
37613762
"user_id"
37623763
],
@@ -3801,6 +3802,11 @@
38013802
"sim": {
38023803
"$ref": "#/definitions/entities.SIM"
38033804
},
3805+
"unarchive_thread": {
3806+
"description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.",
3807+
"type": "boolean",
3808+
"example": false
3809+
},
38043810
"updated_at": {
38053811
"type": "string",
38063812
"example": "2022-06-05T14:26:10.303278+03:00"
@@ -4433,7 +4439,8 @@
44334439
"messages_per_minute",
44344440
"missed_call_auto_reply",
44354441
"phone_number",
4436-
"sim"
4442+
"sim",
4443+
"unarchive_thread"
44374444
],
44384445
"properties": {
44394446
"fcm_token": {
@@ -4470,6 +4477,11 @@
44704477
"description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
44714478
"type": "string",
44724479
"example": "SIM1"
4480+
},
4481+
"unarchive_thread": {
4482+
"description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.",
4483+
"type": "boolean",
4484+
"example": false
44734485
}
44744486
}
44754487
},

api/docs/swagger.yaml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ definitions:
5858
example: 30
5959
type: integer
6060
request_id:
61-
example: bulk-csv-a1B2c3D4e5
61+
example: bulk-httpsms-file.csv
6262
type: string
6363
scheduled_count:
6464
example: 50
@@ -388,6 +388,11 @@ definitions:
388388
type: string
389389
sim:
390390
$ref: '#/definitions/entities.SIM'
391+
unarchive_thread:
392+
description: UnarchiveThread moves an archived message thread back to the
393+
inbox when a new message is received on this phone.
394+
example: false
395+
type: boolean
391396
updated_at:
392397
example: "2022-06-05T14:26:10.303278+03:00"
393398
type: string
@@ -402,6 +407,7 @@ definitions:
402407
- messages_per_minute
403408
- phone_number
404409
- sim
410+
- unarchive_thread
405411
- updated_at
406412
- user_id
407413
type: object
@@ -908,6 +914,11 @@ definitions:
908914
1 SIM slot
909915
example: SIM1
910916
type: string
917+
unarchive_thread:
918+
description: UnarchiveThread moves an archived thread back to the inbox when
919+
a new message is received on this phone.
920+
example: false
921+
type: boolean
911922
required:
912923
- fcm_token
913924
- max_send_attempts
@@ -916,6 +927,7 @@ definitions:
916927
- missed_call_auto_reply
917928
- phone_number
918929
- sim
930+
- unarchive_thread
919931
type: object
920932
requests.UserNotificationUpdate:
921933
properties:

api/pkg/di/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,7 @@ func (container *Container) MessageThreadService() (service *services.MessageThr
11111111
container.Logger(),
11121112
container.Tracer(),
11131113
container.MessageThreadRepository(),
1114+
container.PhoneRepository(),
11141115
container.EventDispatcher(),
11151116
)
11161117
}

api/pkg/entities/phone.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type Phone struct {
2424

2525
MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"`
2626

27+
// UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.
28+
UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"`
29+
2730
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
2831
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
2932
}

api/pkg/handlers/message_handler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func (h *MessageHandler) Index(c fiber.Ctx) error {
265265

266266
messages, err := h.service.GetMessages(ctx, request.ToGetParams(h.userIDFomContext(c)))
267267
if err != nil {
268-
msg := fmt.Sprintf("cannot get messgaes with params [%+#v]", request)
268+
msg := fmt.Sprintf("cannot get messages with params [%+#v]", request)
269269
ctxLogger.Error(stacktrace.Propagate(err, msg))
270270
return h.responseInternalServerError(c)
271271
}
@@ -383,7 +383,7 @@ func (h *MessageHandler) PostReceive(c fiber.Ctx) error {
383383

384384
message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL()))
385385
if err != nil {
386-
msg := fmt.Sprintf("cannot receive message with paylod [%s]", c.Body())
386+
msg := fmt.Sprintf("cannot receive message with payload [%s]", c.Body())
387387
ctxLogger.Error(stacktrace.Propagate(err, msg))
388388
return h.responseInternalServerError(c)
389389
}

api/pkg/requests/phone_update_request.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ type PhoneUpsert struct {
2929

3030
MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."`
3131

32+
// UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.
33+
UnarchiveThread bool `json:"unarchive_thread" example:"false"`
34+
3235
// SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot
3336
SIM string `json:"sim" example:"SIM1"`
3437

@@ -75,6 +78,11 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin
7578
maxSendAttempts = &input.MaxSendAttempts
7679
}
7780

81+
var unarchiveThread *bool
82+
if _, exists := fields["unarchive_thread"]; exists {
83+
unarchiveThread = &input.UnarchiveThread
84+
}
85+
7886
var scheduleID *uuid.UUID
7987
if _, exists := fields["message_send_schedule_id"]; exists {
8088
if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil {
@@ -92,6 +100,7 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin
92100
FcmToken: fcmToken,
93101
UserID: user.ID,
94102
SIM: entities.SIM(input.SIM),
103+
UnarchiveThread: unarchiveThread,
95104
MessageSendScheduleID: scheduleID,
96105
}
97106
}

api/pkg/services/message_service.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,11 +346,13 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa
346346
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
347347
}
348348

349+
owner := phonenumbers.Format(&params.Owner, phonenumbers.E164)
350+
349351
eventPayload := events.MessagePhoneReceivedPayload{
350352
MessageID: messageID,
351353
UserID: params.UserID,
352354
Encrypted: params.Encrypted,
353-
Owner: phonenumbers.Format(&params.Owner, phonenumbers.E164),
355+
Owner: owner,
354356
Contact: params.Contact,
355357
Timestamp: params.Timestamp,
356358
Content: params.Content,

api/pkg/services/message_thread_service.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type MessageThreadService struct {
2121
logger telemetry.Logger
2222
tracer telemetry.Tracer
2323
repository repositories.MessageThreadRepository
24+
phoneRepository repositories.PhoneRepository
2425
eventDispatcher *EventDispatcher
2526
}
2627

@@ -29,13 +30,15 @@ func NewMessageThreadService(
2930
logger telemetry.Logger,
3031
tracer telemetry.Tracer,
3132
repository repositories.MessageThreadRepository,
33+
phoneRepository repositories.PhoneRepository,
3234
eventDispatcher *EventDispatcher,
3335
) (s *MessageThreadService) {
3436
return &MessageThreadService{
3537
logger: logger.WithService(fmt.Sprintf("%T", s)),
3638
tracer: tracer,
3739
eventDispatcher: eventDispatcher,
3840
repository: repository,
41+
phoneRepository: phoneRepository,
3942
}
4043
}
4144

@@ -50,6 +53,14 @@ type MessageThreadUpdateParams struct {
5053
Timestamp time.Time
5154
}
5255

56+
// shouldCheckUnarchive reports whether a thread update is a new inbound message
57+
// landing on an archived thread. Only in that case is the phone's
58+
// UnarchiveThread setting consulted, so the phone is not loaded on the common
59+
// path where the thread is not archived.
60+
func (service *MessageThreadService) shouldCheckUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool {
61+
return thread.IsArchived && params.Status == entities.MessageStatusReceived
62+
}
63+
5364
// DeleteAllForUser deletes all entities.MessageThread for an entities.UserID.
5465
func (service *MessageThreadService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
5566
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
@@ -92,6 +103,16 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me
92103
return nil
93104
}
94105

106+
if service.shouldCheckUnarchive(thread, params) {
107+
phone, phoneErr := service.phoneRepository.Load(ctx, params.UserID, params.Owner)
108+
if phoneErr != nil {
109+
ctxLogger.Warn(stacktrace.Propagate(phoneErr, "cannot load phone [%s] for user [%s] to resolve UnarchiveThread; leaving thread [%s] archived", params.Owner, params.UserID, thread.ID))
110+
} else if phone.UnarchiveThread {
111+
thread.UpdateArchive(false)
112+
ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID))
113+
}
114+
}
115+
95116
if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil {
96117
msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID)
97118
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))

0 commit comments

Comments
 (0)