diff --git a/.gitignore b/.gitignore index e15d590a9..8f4caf4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ SECURITY_AUDIT_REPORT.md *.exe -docs/ .output .agents/ skills-lock.json diff --git a/api/docs/docs.go b/api/docs/docs.go index c57458255..c7fcd1145 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3379,7 +3379,7 @@ const docTemplate = `{ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3760,6 +3760,7 @@ const docTemplate = `{ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3804,6 +3805,11 @@ const docTemplate = `{ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4436,7 +4442,8 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4473,6 +4480,11 @@ const docTemplate = `{ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 009797ca9..25db6fc31 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -3376,7 +3376,7 @@ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3757,6 +3757,7 @@ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3801,6 +3802,11 @@ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4433,7 +4439,8 @@ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4470,6 +4477,11 @@ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb0041717..892c9ae45 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -58,7 +58,7 @@ definitions: example: 30 type: integer request_id: - example: bulk-csv-a1B2c3D4e5 + example: bulk-httpsms-file.csv type: string scheduled_count: example: 50 @@ -388,6 +388,11 @@ definitions: type: string sim: $ref: '#/definitions/entities.SIM' + unarchive_thread: + description: UnarchiveThread moves an archived message thread back to the + inbox when a new message is received on this phone. + example: false + type: boolean updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -402,6 +407,7 @@ definitions: - messages_per_minute - phone_number - sim + - unarchive_thread - updated_at - user_id type: object @@ -908,6 +914,11 @@ definitions: 1 SIM slot example: SIM1 type: string + unarchive_thread: + description: UnarchiveThread moves an archived thread back to the inbox when + a new message is received on this phone. + example: false + type: boolean required: - fcm_token - max_send_attempts @@ -916,6 +927,7 @@ definitions: - missed_call_auto_reply - phone_number - sim + - unarchive_thread type: object requests.UserNotificationUpdate: properties: diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index f84f5b301..fafae1ae7 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -1111,6 +1111,7 @@ func (container *Container) MessageThreadService() (service *services.MessageThr container.Logger(), container.Tracer(), container.MessageThreadRepository(), + container.PhoneRepository(), container.EventDispatcher(), ) } diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 97df66317..4f3c33f28 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -24,6 +24,9 @@ type Phone struct { MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"` + // UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index afe0127a4..9da0ef1e5 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -265,7 +265,7 @@ func (h *MessageHandler) Index(c fiber.Ctx) error { messages, err := h.service.GetMessages(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { - msg := fmt.Sprintf("cannot get messgaes with params [%+#v]", request) + msg := fmt.Sprintf("cannot get messages with params [%+#v]", request) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } @@ -383,7 +383,7 @@ func (h *MessageHandler) PostReceive(c fiber.Ctx) error { message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - msg := fmt.Sprintf("cannot receive message with paylod [%s]", c.Body()) + msg := fmt.Sprintf("cannot receive message with payload [%s]", c.Body()) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 462d6428e..96b2882e1 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -29,6 +29,9 @@ type PhoneUpsert struct { MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` + // UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" example:"false"` + // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` @@ -75,6 +78,11 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin maxSendAttempts = &input.MaxSendAttempts } + var unarchiveThread *bool + if _, exists := fields["unarchive_thread"]; exists { + unarchiveThread = &input.UnarchiveThread + } + var scheduleID *uuid.UUID if _, exists := fields["message_send_schedule_id"]; exists { if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil { @@ -92,6 +100,7 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin FcmToken: fcmToken, UserID: user.ID, SIM: entities.SIM(input.SIM), + UnarchiveThread: unarchiveThread, MessageSendScheduleID: scheduleID, } } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 56766c981..f54feafd7 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -346,11 +346,13 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + owner := phonenumbers.Format(¶ms.Owner, phonenumbers.E164) + eventPayload := events.MessagePhoneReceivedPayload{ MessageID: messageID, UserID: params.UserID, Encrypted: params.Encrypted, - Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164), + Owner: owner, Contact: params.Contact, Timestamp: params.Timestamp, Content: params.Content, diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index d2027506f..31287ebc1 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -21,6 +21,7 @@ type MessageThreadService struct { logger telemetry.Logger tracer telemetry.Tracer repository repositories.MessageThreadRepository + phoneRepository repositories.PhoneRepository eventDispatcher *EventDispatcher } @@ -29,6 +30,7 @@ func NewMessageThreadService( logger telemetry.Logger, tracer telemetry.Tracer, repository repositories.MessageThreadRepository, + phoneRepository repositories.PhoneRepository, eventDispatcher *EventDispatcher, ) (s *MessageThreadService) { return &MessageThreadService{ @@ -36,6 +38,7 @@ func NewMessageThreadService( tracer: tracer, eventDispatcher: eventDispatcher, repository: repository, + phoneRepository: phoneRepository, } } @@ -50,6 +53,14 @@ type MessageThreadUpdateParams struct { Timestamp time.Time } +// shouldCheckUnarchive reports whether a thread update is a new inbound message +// landing on an archived thread. Only in that case is the phone's +// UnarchiveThread setting consulted, so the phone is not loaded on the common +// path where the thread is not archived. +func (service *MessageThreadService) shouldCheckUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool { + return thread.IsArchived && params.Status == entities.MessageStatusReceived +} + // DeleteAllForUser deletes all entities.MessageThread for an entities.UserID. func (service *MessageThreadService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) @@ -92,6 +103,16 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me return nil } + if service.shouldCheckUnarchive(thread, params) { + phone, phoneErr := service.phoneRepository.Load(ctx, params.UserID, params.Owner) + if phoneErr != nil { + 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)) + } else if phone.UnarchiveThread { + thread.UpdateArchive(false) + ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) + } + } + if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil { msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go new file mode 100644 index 000000000..7e48ab433 --- /dev/null +++ b/api/pkg/services/message_thread_service_test.go @@ -0,0 +1,23 @@ +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestShouldCheckUnarchive(t *testing.T) { + service := &MessageThreadService{} + + archived := &entities.MessageThread{IsArchived: true} + notArchived := &entities.MessageThread{IsArchived: false} + + received := MessageThreadUpdateParams{Status: entities.MessageStatusReceived} + sent := MessageThreadUpdateParams{Status: entities.MessageStatusSent} + + assert.True(t, service.shouldCheckUnarchive(archived, received), "archived + inbound -> consult phone setting") + assert.False(t, service.shouldCheckUnarchive(archived, sent), "outbound status -> no check") + assert.False(t, service.shouldCheckUnarchive(notArchived, received), "already unarchived -> no check") + assert.False(t, service.shouldCheckUnarchive(notArchived, sent), "not archived + outbound -> no check") +} diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index ae863d32b..2e80abfa1 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -104,6 +104,7 @@ type PhoneUpsertParams struct { WebhookURL *string MessageExpirationDuration *time.Duration MissedCallAutoReply *string + UnarchiveThread *bool SIM entities.SIM MessageSendScheduleID *uuid.UUID Source string @@ -312,6 +313,10 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa phone.MissedCallAutoReply = params.MissedCallAutoReply } + if params.UnarchiveThread != nil { + phone.UnarchiveThread = *params.UnarchiveThread + } + phone.SIM = params.SIM phone.MessageSendScheduleID = params.MessageSendScheduleID diff --git a/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md b/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md new file mode 100644 index 000000000..312033520 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md @@ -0,0 +1,109 @@ +# Affiliates Landing Page — Design + +**Date:** 2026-07-05 +**Component:** `web/` (Nuxt 4 SPA, Vuetify 4) + +## Goal + +Create a marketing landing page at `/affiliates` that convinces people to join the +httpSMS affiliate program, and replace the current external "Affiliates" footer link +(`https://httpsms.lemonsqueezy.com/affiliates`) with a `NuxtLink` to this new internal page. + +All primary calls-to-action link to the LemonSqueezy affiliate signup: +`https://affiliates.lemonsqueezy.com/programs/httpsms` (opens in a new tab). + +## Files + +- **New:** `web/app/pages/affiliates/index.vue` — the landing page. +- **Edit:** `web/app/layouts/website.vue` — change the footer "Affiliates" link from the + external `` to + ``, keeping the existing `mdiShieldStar` warning icon and + `text-white text-decoration-none footer-link` classes. + +## Conventions to follow + +- `