From 8010ce30a27e8f78ec2d5c41b7df80e905313bc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 18 Jul 2026 11:48:04 +0300 Subject: [PATCH 01/93] 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> --- .gitignore | 1 - api/docs/docs.go | 16 +- api/docs/swagger.json | 16 +- api/docs/swagger.yaml | 14 +- api/pkg/di/container.go | 1 + api/pkg/entities/phone.go | 3 + api/pkg/handlers/message_handler.go | 4 +- api/pkg/requests/phone_update_request.go | 9 + api/pkg/services/message_service.go | 4 +- api/pkg/services/message_thread_service.go | 21 ++ .../services/message_thread_service_test.go | 23 ++ api/pkg/services/phone_service.go | 5 + ...26-07-05-affiliates-landing-page-design.md | 109 ++++++++ .../2026-07-13-getting-started-page-design.md | 88 +++++++ tests/README.md | 1 + tests/unarchive_thread_integration_test.go | 236 ++++++++++++++++++ web/app/pages/settings/index.vue | 9 + web/app/stores/phones.ts | 1 + web/shared/types/api.ts | 12 +- 19 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 api/pkg/services/message_thread_service_test.go create mode 100644 docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md create mode 100644 docs/superpowers/specs/2026-07-13-getting-started-page-design.md create mode 100644 tests/unarchive_thread_integration_test.go 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 + +- `&"}`) + html := string(rich) + + assert.Contains(t, plain, `&"}`) + html := string(rich) + + assert.Contains(t, plain, ` + + diff --git a/web/app/layouts/website.vue b/web/app/layouts/website.vue index 1aadb1b64..3b6565a5a 100644 --- a/web/app/layouts/website.vue +++ b/web/app/layouts/website.vue @@ -80,7 +80,11 @@ function goToPricing() { Blog  For Free - - Dashboard - + + Dashboard + + + diff --git a/web/app/middleware/redirectToThreads.ts b/web/app/middleware/redirectToThreads.ts new file mode 100644 index 000000000..a605286fb --- /dev/null +++ b/web/app/middleware/redirectToThreads.ts @@ -0,0 +1,11 @@ +import { STORAGE_KEY } from '~/stores/redirectPreference' + +export default defineNuxtRouteMiddleware(() => { + try { + if (localStorage.getItem(STORAGE_KEY) === 'true') { + return navigateTo('/threads', { replace: true }) + } + } catch (error) { + console.error(error) + } +}) diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue index 524c6637b..9790b5e7f 100644 --- a/web/app/pages/index.vue +++ b/web/app/pages/index.vue @@ -28,6 +28,7 @@ import { definePageMeta({ layout: 'website', + middleware: ['redirect-to-threads'], }) useSeoMeta({ diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index ea1752533..1f381416b 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -47,6 +47,7 @@ const authStore = useAuthStore() const phonesStore = usePhonesStore() const billingStore = useBillingStore() const notificationsStore = useNotificationsStore() +const redirectPreferenceStore = useRedirectPreferenceStore() const firebaseUser = ref(null) const gravatarUrl = ref(null) @@ -773,6 +774,7 @@ async function deleteUserAccount() { await signOut(auth) authStore.resetState() phonesStore.resetState() + redirectPreferenceStore.resetState() notificationsStore.addNotification({ type: 'info', message: 'You have successfully logged out', diff --git a/web/app/stores/redirectPreference.ts b/web/app/stores/redirectPreference.ts new file mode 100644 index 000000000..030def894 --- /dev/null +++ b/web/app/stores/redirectPreference.ts @@ -0,0 +1,47 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const STORAGE_KEY = 'httpsms_redirect_to_threads' + +function readFlag(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true' + } catch (error) { + console.error(error) + return false + } +} + +export const useRedirectPreferenceStore = defineStore( + 'redirectPreference', + () => { + const enabled = ref(readFlag()) + const dismissedThisSession = ref(false) + + function enable() { + try { + localStorage.setItem(STORAGE_KEY, 'true') + enabled.value = true + navigateTo('/threads', { replace: true }) + } catch (error) { + console.error(error) + } + } + + function dismiss() { + dismissedThisSession.value = true + } + + function resetState() { + enabled.value = false + dismissedThisSession.value = false + try { + localStorage.removeItem(STORAGE_KEY) + } catch (error) { + console.error(error) + } + } + + return { enabled, dismissedThisSession, enable, dismiss, resetState } + }, +) From 9444545e67e9cd904095fc1acc81c15d315f5d0b Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 19:51:31 +0300 Subject: [PATCH 12/93] style: add arrow position --- web/app/components/RedirectPromptPopover.vue | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/app/components/RedirectPromptPopover.vue b/web/app/components/RedirectPromptPopover.vue index 859a71dac..c2de18200 100644 --- a/web/app/components/RedirectPromptPopover.vue +++ b/web/app/components/RedirectPromptPopover.vue @@ -31,7 +31,7 @@ const menuOpen = computed({ :open-on-click="false" :close-on-content-click="false" > - + Skip this page next time? @@ -49,10 +49,9 @@ const menuOpen = computed({ - Always open dashboard - + Always open dashboard From 78e1d183a13f5e43143fba2c6dae1d883c05dcc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 20:13:15 +0300 Subject: [PATCH 13/93] docs: add contacts feature design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577 --- .../2026-07-19-contacts-feature-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-contacts-feature-design.md diff --git a/docs/superpowers/specs/2026-07-19-contacts-feature-design.md b/docs/superpowers/specs/2026-07-19-contacts-feature-design.md new file mode 100644 index 000000000..58f9d9e40 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-contacts-feature-design.md @@ -0,0 +1,252 @@ +# Contacts Feature — Design + +Date: 2026-07-19 +Status: Approved (pending implementation plan) + +## 1. Summary + +Add a **Contacts** feature to httpSMS. Users can store contacts (name, emails, +phone numbers, and free-form properties) and manage them through a dedicated web +page and a REST API. Once contacts exist, the threads page displays the +contact's **name** instead of the raw phone number. + +Design priority (explicit user goal): resolve contact names into the threads +page with **as few database queries as possible**, and make **changing a +contact's name cheap** (no write amplification across threads). + +## 2. Goals + +- CRUD for contacts via a REST API under `/v1/contacts`. +- A single create endpoint that accepts **one or many** contacts in one request. +- Import contacts from **CSV** (Excel/XLSX is **not** supported for contacts). +- A contact has: `Name` (required), `Emails` (optional array), `PhoneNumbers` + (array, >= 1), and free-form `Properties` (`map[string]string`). +- Contacts are **global to the user account** (name shows across all the user's + owner phones). +- Threads display the resolved contact name (and expose full contact details) + when requested, everywhere the peer number is shown (threads list + thread + header/title). +- Dedicated Contacts web page with full CRUD + import. + +## 3. Non-goals / Explicit decisions + +- **No uniqueness constraint** on phone numbers. Multiple contacts may share a + phone number. There is **no** normalized `contact_phone_numbers` lookup table + and **no** DB-level unique index on numbers. +- When a thread's number matches **multiple** contacts, the **most recently + updated** contact wins for display. +- Thread-name resolution is **opt-in** per request (default off). +- `Properties` are API/JSON only; not part of the flat CSV template. + +## 4. Data model + +### 4.1 `Contact` entity (`api/pkg/entities/contact.go`) + +| Field | Type | GORM / notes | +| -------------- | ------------------- | --------------------------------------------- | +| `ID` | `uuid.UUID` | `primaryKey;type:uuid` | +| `UserID` | `entities.UserID` | indexed | +| `Name` | `string` | required | +| `Emails` | `pq.StringArray` | `gorm:"type:text[]"` `swaggertype:"array,string"`; optional, each validated | +| `PhoneNumbers` | `pq.StringArray` | `gorm:"type:text[]"` `swaggertype:"array,string"`; >= 1, each valid E.164 | +| `Properties` | `ContactProperties` | `gorm:"type:jsonb"`; free-form key/value map | +| `CreatedAt` | `time.Time` | | +| `UpdatedAt` | `time.Time` | | + +Follows the existing `pq.StringArray` convention used in `webhook.go` and +`phone_api_key.go`. Auto-migrated in `pkg/di/container.go` alongside the other +entities. + +### 4.2 `ContactProperties` custom type + +A small custom type to avoid adding a new dependency (`gorm.io/datatypes` is not +currently used): + +```go +// ContactProperties is a free-form key/value map persisted as a jsonb column. +type ContactProperties map[string]string + +func (p ContactProperties) Value() (driver.Value, error) // json.Marshal -> []byte +func (p *ContactProperties) Scan(src any) error // json.Unmarshal from []byte/string +``` + +Includes unit tests for `Value`/`Scan` round-trips (nil, empty, populated). + +### 4.3 `MessageThread` additions (`api/pkg/entities/message_thread.go`) + +Add a **non-persisted** field carrying the resolved contact: + +```go +ContactDetails *Contact `json:"contact_details,omitempty" gorm:"-"` +``` + +- `gorm:"-"` — never read/written to the DB. +- `omitempty` — absent from responses unless resolution attached it. +- Named `ContactDetails` (JSON `contact_details`) to avoid colliding with the + existing `Contact string json:"contact"` field, which holds the peer phone + number. + +## 5. API + +New `ContactHandler` mirroring existing handler/service/repository/validator +layering. Routes registered via the `handler.register(...)` helper (Fiber v3), +wired in the DI container. All routes are under `/v1/` and behind the standard +auth middleware chain. + +| Method | Route | Purpose | +| -------- | ------------------------ | ------------------------------------------------------------- | +| `GET` | `/v1/contacts` | List (paginated; `?query=` searches name / email / number) | +| `POST` | `/v1/contacts` | Create **one or many** — body is a JSON array of contacts | +| `POST` | `/v1/contacts/upload` | Import from CSV (`document` multipart form field; CSV only) | +| `PUT` | `/v1/contacts/:contactID`| Update name / emails / phone numbers / properties | +| `DELETE` | `/v1/contacts/:contactID`| Delete a contact | + +### 5.1 Create (one or many) + +Request body (array form or `{ "contacts": [...] }`): + +```json +{ + "contacts": [ + { + "name": "Alice Smith", + "emails": ["alice@example.com"], + "phone_numbers": ["+18005550199", "+18005550100"], + "properties": { "company": "Acme", "role": "CTO" } + } + ] +} +``` + +- Per-item validation with row-indexed error messages (mirroring bulk-message + validation style using `url.Values`). +- Batch capped at <= 1000 contacts. +- Cache invalidated **once** after the batch commits. + +### 5.2 CSV import (CSV only) + +- Reuses the bulk-message CSV parsing pattern (`csvutil`; <= 500 KB; <= 1000 + rows). **Excel/XLSX is not supported** — only `text/csv` / `.csv` is accepted; + any other file type returns a validation error. +- Columns: `Name, Emails, PhoneNumbers`. `Emails` and `PhoneNumbers` cells hold + multiple values separated by `;` (or `,`). `Properties` is not part of the + flat template. +- Published template: `httpsms-contacts.csv` (same location/convention as the + bulk-message template). + +### 5.3 Validation rules + +- `Name` required and non-empty per contact. +- Each phone number must parse as E.164 (`nyaruka/phonenumbers`); >= 1 required. +- Each email (if present) must be a valid email. +- `properties` keys/values are free-form strings. + +## 6. Thread name resolution + caching + +### 6.1 Contact map cache + +- A per-user map `phone_number -> *Contact` is built from **all** the user's + contacts. On phone-number collisions, the **most recently updated** contact + wins (sort by `UpdatedAt` ascending while building so later writes overwrite). +- Serialized as JSON in `cache.Cache` under key `contacts.map.`, + TTL ~24h. +- `cache.Cache` exposes only `Get`/`Set` (no delete), so **invalidation = + overwrite** the key with an empty marker after any contact mutation; the next + request that needs it lazily rebuilds from the DB. + +### 6.2 `GetThreads` flow + +1. Load threads (existing single query, unchanged). +2. **If** the request's `Contacts` flag is true: fetch the user's contact map + (cache hit → 0 DB queries; miss → one query to rebuild), then attach + `ContactDetails` to each thread in memory by looking up `thread.contact`. +3. If the flag is false/absent: skip resolution entirely (no cache/DB work); + behavior identical to today. + +Cost profile: + +- Threads read: **1 DB query** (unchanged). +- Contact resolution: usually a **cache hit → 0 DB queries**; DB touched only on + cache miss or right after a contact write. +- Contact **name change**: single-row `UPDATE` + cache invalidation. + **Zero thread writes / zero propagation.** + +### 6.3 Opt-in filter + +- `GET /v1/message-threads` gains an optional query param `?contacts=true` + bound onto `requests.MessageThreadIndex` (`Contacts bool`, default `false`). +- Threaded through `MessageThreadGetParams` to the service, controlling whether + step 2 above runs. + +## 7. Frontend (Nuxt 4 / Pinia) + +- **`useContactsStore`** (`web/app/stores/contacts.ts`): `loadContacts`, + `saveContacts` (array), `updateContact`, `deleteContact`, `uploadCsv`. +- **Contacts page** (`web/app/pages/contacts/index.vue`) — layout modeled on the + provided reference (Plunk-style): + - **Header row**: large `Contacts` title (`text-display-large`, no glowing + gradient) with a subtitle like `Manage your contacts. {total} total`, and, + aligned top-right, an outlined **Import CSV** button and a filled primary + **Add Contact** button. + - **Search bar** below the header filtering by name / email / phone number + (drives the `?query=` API param, debounced). + - **Data table** (`VDataTable`) with columns: `Name`, `Phone Numbers`, + `Emails`, `Created`, `Updated`, and a right-aligned **Actions** column with + per-row **edit** (pencil, `mdiPencil`) and **delete** (trash, + `mdiDelete`) icon buttons. + - Timestamps rendered relatively (e.g. "31 minutes ago") via `useFilters()`. + - Pagination for large lists. + - **Modals** (all `VDialog` with `opacity="0.9"`, Close button + `color="warning"`): + - **Add / Edit Contact** dialog — form for `Name`, repeatable + `PhoneNumbers`, repeatable `Emails`, and free-form `Properties` + (key/value rows). Same dialog component for create and edit. + - **Delete Contact** confirmation dialog. + - **Import CSV** dialog — file input accepting `.csv` only, a link to the + `httpsms-contacts.csv` template, and inline row-indexed error display. + - Hyperlinks use `text-decoration-none hover:text-decoration-underline`. +- **Threads UI**: + - `threads` store `loadThreads` passes `contacts: true`. + - `MessageThread.vue` title and `MessageThreadHeader.vue` render + `thread.contact_details?.name ?? formatPhoneNumber(thread.contact)`; the + avatar uses the resolved name's first letter when present. + - Filter helpers pulled from `useFilters()` in ` + + +``` + +- [ ] **Step 2: Lint the page** + +Run: `cd web && pnpm lint:js && pnpm lint:prettier` +Expected: no errors for `app/pages/contacts/index.vue`. If Prettier reports formatting, run `pnpm lintfix` and re-check. + +- [ ] **Step 3: Build the site to confirm it compiles** + +Run: `cd web && pnpm generate` +Expected: build completes without type errors (the `/contacts` route is emitted). + +- [ ] **Step 4: Commit** + +```bash +git add web/app/pages/contacts/index.vue +git commit -m "feat(web): add contacts page with add/edit/delete/import dialogs" +``` + +--- + +### Task 13: Show contact names on threads + Contacts nav link + +**Files:** +- Modify: `web/app/stores/threads.ts` (send `contacts: true`) +- Modify: `web/app/components/MessageThread.vue` (list title + avatar initial) +- Modify: `web/app/pages/threads/[id]/index.vue` (toolbar title) +- Modify: `web/app/components/MessageThreadHeader.vue` (Contacts nav item + icon import) + +**Interfaces:** +- Consumes: `EntitiesMessageThread.contact_details?: EntitiesContact` (Task 10), the `/contacts` route (Task 12). +- Produces: no new exports — UI wiring only. + +- [ ] **Step 1: Request contact resolution when loading threads** + +In `web/app/stores/threads.ts`, add `contacts: true` to the `loadThreads` request params: + +```ts + const response = await apiFetch<{ data: EntitiesMessageThread[] }>( + '/v1/message-threads', + { + params: { + owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, + limit: 100, + is_archived: archivedThreads.value, + contacts: true, + }, + }, + ) +``` + +- [ ] **Step 2: Show the contact name in the threads list** + +In `web/app/components/MessageThread.vue`, replace the list-item title expression: + +```vue + {{ + thread.contact_details?.name ?? formatPhoneNumber(thread.contact) + }} +``` + +And update the avatar initial to prefer the contact name (replace the existing ` Search Messages + + + Contacts + Settings diff --git a/web/app/composables/useFilters.ts b/web/app/composables/useFilters.ts index c84157ea5..2b54bbb43 100644 --- a/web/app/composables/useFilters.ts +++ b/web/app/composables/useFilters.ts @@ -7,6 +7,8 @@ import { formatBillingPeriod, formatBillingPeriodDateOrdinal, humanizeTime, + humanizeTimeShort, + startsWithLetter, } from '../utils/filters' import { capitalize } from '../utils/capitalize' @@ -18,8 +20,10 @@ export function useFilters() { formatMoney, formatDecimal, formatBillingPeriod, + humanizeTimeShort, formatBillingPeriodDateOrdinal, humanizeTime, + startsWithLetter, capitalize, } } diff --git a/web/app/pages/billing/index.vue b/web/app/pages/billing/index.vue index 555bd129c..c5af811e3 100644 --- a/web/app/pages/billing/index.vue +++ b/web/app/pages/billing/index.vue @@ -27,7 +27,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const billingStore = useBillingStore() const notificationsStore = useNotificationsStore() diff --git a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue index 5e7e0a6f9..bda47bca9 100644 --- a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue +++ b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue @@ -1,10 +1,8 @@ + + diff --git a/web/app/pages/heartbeats/[id].vue b/web/app/pages/heartbeats/[id].vue index 8a05d6944..3ef5d212c 100644 --- a/web/app/pages/heartbeats/[id].vue +++ b/web/app/pages/heartbeats/[id].vue @@ -17,7 +17,7 @@ useHead({ }) const route = useRoute() -const { mdAndDown, mdAndUp, lgAndUp } = useDisplay() +const { mdAndDown, mdAndUp, lgAndUp } = useVDisplay() const authStore = useAuthStore() const phonesStore = usePhonesStore() const { formatPhoneNumber, formatTimestamp } = useFilters() diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue index 281745691..be30493f7 100644 --- a/web/app/pages/index.vue +++ b/web/app/pages/index.vue @@ -43,7 +43,7 @@ useSeoMeta({ }) const config = useRuntimeConfig() -const { lgAndUp, mdAndUp, mdAndDown, md, smAndDown, xl } = useDisplay() +const { lgAndUp, mdAndUp, mdAndDown, md, smAndDown, xl } = useVDisplay() const selectedTab = ref('javascript') const yearlyPricing = ref(false) diff --git a/web/app/pages/messages/index.vue b/web/app/pages/messages/index.vue index 0a6d53a99..3dd1a544a 100644 --- a/web/app/pages/messages/index.vue +++ b/web/app/pages/messages/index.vue @@ -17,7 +17,7 @@ useHead({ }) const router = useRouter() -const { mdAndDown, mdAndUp } = useDisplay() +const { mdAndDown, mdAndUp } = useVDisplay() const notificationsStore = useNotificationsStore() const phonesStore = usePhonesStore() const { useApi } = useApiComposable() diff --git a/web/app/pages/phone-api-keys/index.vue b/web/app/pages/phone-api-keys/index.vue index 27804cbde..df13c5a67 100644 --- a/web/app/pages/phone-api-keys/index.vue +++ b/web/app/pages/phone-api-keys/index.vue @@ -16,7 +16,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const appStore = useAppStore() const phonesStore = usePhonesStore() diff --git a/web/app/pages/search-messages/index.vue b/web/app/pages/search-messages/index.vue index 925800d75..0e5419f71 100644 --- a/web/app/pages/search-messages/index.vue +++ b/web/app/pages/search-messages/index.vue @@ -42,7 +42,7 @@ useHead({ const route = useRoute() const config = useRuntimeConfig() -const { mdAndUp, smAndDown, lgAndUp } = useDisplay() +const { mdAndUp, smAndDown, lgAndUp } = useVDisplay() const messagesStore = useMessagesStore() const phonesStore = usePhonesStore() const authStore = useAuthStore() diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index 1f381416b..63a8c2975 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -42,7 +42,7 @@ useHead({ const config = useRuntimeConfig() const route = useRoute() const router = useRouter() -const { mdAndDown, mdAndUp, lgAndUp, xlAndUp, smAndUp } = useDisplay() +const { mdAndDown, mdAndUp, lgAndUp, xlAndUp, smAndUp } = useVDisplay() const authStore = useAuthStore() const phonesStore = usePhonesStore() const billingStore = useBillingStore() diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 73632a64c..f933973dd 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -14,12 +14,14 @@ import { mdiAccount, mdiRefresh, mdiContentCopy, + mdiAccountPlus, + mdiSquareEditOutline, } from '@mdi/js' import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { isValidPhoneNumber } from 'libphonenumber-js' +import { storeToRefs } from 'pinia' import type { EntitiesMessage } from '~~/shared/types/api' -import { startsWithLetter } from '~/utils/filters' definePageMeta({ middleware: ['auth'], @@ -32,13 +34,14 @@ useHead({ const route = useRoute() const router = useRouter() const config = useRuntimeConfig() -const { lgAndUp, mdAndDown, mdAndUp } = useDisplay() -const { formatPhoneNumber } = useFilters() +const { lgAndUp, mdAndDown, mdAndUp } = useVDisplay() +const { formatPhoneNumber, startsWithLetter } = useFilters() const notificationsStore = useNotificationsStore() const authStore = useAuthStore() const phonesStore = usePhonesStore() const threadsStore = useThreadsStore() const messagesStore = useMessagesStore() +const { currentThread } = storeToRefs(threadsStore) const formMessage = ref('') const submitting = ref(false) @@ -46,6 +49,7 @@ const loadingMessages = ref(false) const hideMessages = ref(true) const messages = ref([]) const selectedMenuItem = ref(-1) +const contactDialog = ref(false) const messageBody = ref(null) const form = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null) @@ -59,7 +63,7 @@ const formMessageRules = [ let webhookChannel: Channel | null = null const contactIsPhoneNumber = computed(() => { - const thread = threadsStore.currentThread + const thread = currentThread.value if (!thread) return false return isValidPhoneNumber(thread.contact) || !isNaN(Number(thread.contact)) }) @@ -67,7 +71,7 @@ const contactIsPhoneNumber = computed(() => { const messageVisibility = computed(() => hideMessages.value ? 'hidden' : 'visible', ) -const contact = computed(() => threadsStore.currentThread?.contact ?? '') +const contact = computed(() => currentThread.value?.contact ?? '') function isMT(message: EntitiesMessage): boolean { return message.type === 'mobile-terminated' @@ -104,6 +108,18 @@ function formatAttachmentName(url: string): string { return url } +function currentThreadContactTitle(): string { + const thread = currentThread.value + if (!thread) return '' + return ( + thread.contact_details?.name?.trim() || formatPhoneNumber(thread.contact) + ) +} + +async function refreshThreadContact() { + await threadsStore.loadThreads() +} + function scrollToElement() { const el = messageBody.value if (el) { @@ -155,7 +171,7 @@ async function loadData() { async function archiveThread() { await threadsStore.updateThread({ - threadId: threadsStore.currentThread!.id, + threadId: currentThread.value!.id, isArchived: true, }) await router.push('/threads') @@ -163,7 +179,7 @@ async function archiveThread() { async function unArchiveThread() { await threadsStore.updateThread({ - threadId: threadsStore.currentThread!.id, + threadId: currentThread.value!.id, isArchived: false, }) await router.push('/threads') @@ -218,7 +234,7 @@ async function sendMessage(event: KeyboardEvent | Event) { submitting.value = true await messagesStore.sendMessage({ from: phonesStore.owner!, - to: threadsStore.currentThread!.contact, + to: currentThread.value!.contact, content: formMessage.value, sim: 'DEFAULT', }) @@ -267,8 +283,17 @@ onBeforeUnmount(() => { - - {{ formatPhoneNumber(threadsStore.currentThread.contact) }} + + + + +