From f03d163e7f0f61874c0d13f5b527eb6862066c38 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 18 Jul 2026 10:38:05 +0300 Subject: [PATCH 01/93] fix(web): preserve thread archive view (#952) * docs: add design spec for per-phone unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * docs: add implementation plan for unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * docs: design message archive UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * docs: plan message archive UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * fix(web): highlight active thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * fix(web): preserve thread archive filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-07-18-message-thread-archive-ui.md | 241 +++++ .../2026-07-18-unarchive-thread-on-receive.md | 848 ++++++++++++++++++ ...-07-18-message-thread-archive-ui-design.md | 37 + ...7-18-unarchive-thread-on-receive-design.md | 118 +++ web/app/components/MessageThread.vue | 1 + web/app/pages/threads/[id]/index.vue | 8 +- web/app/stores/threads.ts | 10 +- 7 files changed, 1255 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md create mode 100644 docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md create mode 100644 docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md create mode 100644 docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md diff --git a/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md b/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md new file mode 100644 index 000000000..3803169c7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md @@ -0,0 +1,241 @@ +# Message Thread Archive UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Highlight the active message thread with the primary color and make archive actions return to the current filtered thread list with a success notification. + +**Architecture:** Keep archive state transitions in the Pinia threads store: perform the API request, remove the moved thread from the visible collection, clear the selection, and notify. Keep route navigation in the thread page, and use Vuetify's built-in active-item color handling for the list styling. + +**Tech Stack:** Nuxt 4, Vue 3, Pinia 3, Vuetify 4, TypeScript, pnpm + +## Global Constraints + +- Preserve the current `archivedThreads` filter after archive and unarchive actions. +- Show exactly `Archived` or `Unarchived` as a success notification after a successful update. +- Do not change local thread state or navigate when the API request fails. +- Use existing dependencies and validation commands only; the web package has no configured automated test runner. +- Include the repository's `Co-authored-by` and `Copilot-Session` trailers in every commit. + +--- + +### Task 1: Active Thread Primary Color + +**Files:** +- Modify: `web/app/components/MessageThread.vue:94-100` + +**Interfaces:** +- Consumes: `threadsStore.threadId` and each thread's route-backed `v-list-item` +- Produces: Vuetify active-item styling through `color="primary"` + +- [ ] **Step 1: Run the source assertion to verify the active color is missing** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/components/MessageThread.vue','utf8');if(!/ +``` + +- [ ] **Step 3: Re-run the source assertion** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/components/MessageThread.vue','utf8');if(!/`nCopilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc" +``` + +### Task 2: Archive Without Switching Filters + +**Files:** +- Modify: `web/app/stores/threads.ts:78-91` +- Modify: `web/app/pages/threads/[id]/index.vue:146-166` + +**Interfaces:** +- Consumes: `updateThread({ threadId: string, isArchived: boolean }): Promise` +- Produces: successful archive updates that remove the moved thread, clear `threadId`, notify, and allow the page to navigate to `/threads` + +- [ ] **Step 1: Run source assertions to verify the required behavior is missing** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/stores/threads.ts','utf8');for(const x of [\"threads.value = threads.value.filter\",'threadId.value = null',\"payload.isArchived ? 'Archived' : 'Unarchived'\"])if(!s.includes(x))throw new Error('archive state transition missing: '+x)" +node -e "const fs=require('fs');const s=fs.readFileSync('app/pages/threads/[id]/index.vue','utf8');const matches=s.match(/await router\.push\('\/threads'\)/g)||[];if(matches.length<3)throw new Error('archive navigation missing')" +``` + +Expected: both commands FAIL because the store still switches `archivedThreads` and the archive actions do not navigate. + +- [ ] **Step 2: Replace the store's archive-filter switch and reload** + +Replace the statements after the successful `apiFetch` call in `updateThread` with: + +```ts +threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, +) +threadId.value = null +notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', +}) +``` + +The complete function must remain: + +```ts +async function updateThread(payload: { + threadId: string + isArchived: boolean +}) { + await apiFetch(`/v1/message-threads/${payload.threadId}`, { + method: 'PUT', + body: { is_archived: payload.isArchived }, + }) + threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, + ) + threadId.value = null + notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', + }) +} +``` + +- [ ] **Step 3: Route to the current filtered list after archive** + +Change `archiveThread` to: + +```ts +async function archiveThread() { + await threadsStore.updateThread({ + threadId: threadsStore.currentThread!.id, + isArchived: true, + }) + await router.push('/threads') +} +``` + +- [ ] **Step 4: Route to the current filtered list after unarchive** + +Change `unArchiveThread` to: + +```ts +async function unArchiveThread() { + await threadsStore.updateThread({ + threadId: threadsStore.currentThread!.id, + isArchived: false, + }) + await router.push('/threads') +} +``` + +- [ ] **Step 5: Re-run the source assertions** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/stores/threads.ts','utf8');for(const x of [\"threads.value = threads.value.filter\",'threadId.value = null',\"payload.isArchived ? 'Archived' : 'Unarchived'\"])if(!s.includes(x))throw new Error('archive state transition missing: '+x);if(s.includes('archivedThreads.value = payload.isArchived'))throw new Error('archive filter still switches')" +node -e "const fs=require('fs');const s=fs.readFileSync('app/pages/threads/[id]/index.vue','utf8');const matches=s.match(/await router\.push\('\/threads'\)/g)||[];if(matches.length<3)throw new Error('archive navigation missing')" +``` + +Expected: both commands exit with code 0. + +- [ ] **Step 6: Lint the changed behavior** + +Run from `web/`: + +```powershell +pnpm exec eslint app/stores/threads.ts "app/pages/threads/[id]/index.vue" +pnpm exec stylelint "app/pages/threads/[id]/index.vue" +pnpm exec prettier --check app/stores/threads.ts "app/pages/threads/[id]/index.vue" +``` + +Expected: all commands exit with code 0. + +- [ ] **Step 7: Commit** + +```powershell +git add web/app/stores/threads.ts "web/app/pages/threads/[id]/index.vue" +git commit -m "fix(web): preserve thread archive filter" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`nCopilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc" +``` + +### Task 3: Production Validation + +**Files:** +- Verify: `web/app/components/MessageThread.vue` +- Verify: `web/app/stores/threads.ts` +- Verify: `web/app/pages/threads/[id]/index.vue` + +**Interfaces:** +- Consumes: completed UI and archive behavior from Tasks 1 and 2 +- Produces: lint-clean, production-generated frontend output + +- [ ] **Step 1: Run all targeted lint checks together** + +Run from `web/`: + +```powershell +pnpm exec eslint app/components/MessageThread.vue app/stores/threads.ts "app/pages/threads/[id]/index.vue" +pnpm exec stylelint app/components/MessageThread.vue "app/pages/threads/[id]/index.vue" +pnpm exec prettier --check app/components/MessageThread.vue app/stores/threads.ts "app/pages/threads/[id]/index.vue" +``` + +Expected: all commands exit with code 0. + +- [ ] **Step 2: Generate the production static site** + +Run from `web/`: + +```powershell +pnpm generate +``` + +Expected: Nuxt generation completes successfully and writes the static output. + +- [ ] **Step 3: Check the final diff** + +Run from the repository root: + +```powershell +git diff main...HEAD --check +git status --short +``` + +Expected: no whitespace errors and a clean working tree. diff --git a/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md b/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md new file mode 100644 index 000000000..70ec8969b --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md @@ -0,0 +1,848 @@ +# Unarchive Thread on Receive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an archived message thread automatically move back to the inbox when a new inbound message is received, configurable per phone number. + +**Architecture:** Add a per-phone boolean `UnarchiveThread` (default false). It is copied onto the `message.phone.received` event payload when a message is received, carried through the thread listener into `MessageThreadUpdateParams`, and applied by `MessageThreadService.UpdateThread`, which clears `IsArchived` only for inbound (received) messages when the flag is true. A per-phone toggle is exposed in the web settings page. + +**Tech Stack:** Go 1.x (Fiber v3, GORM, stacktrace, testify), Nuxt 4 / Vue 3 (Vuetify 4, Pinia), swaggo/swag, swagger-typescript-api. + +## Global Constraints + +- Field name is exactly `UnarchiveThread` (Go) / `unarchive_thread` (JSON) everywhere. +- Default value is `false` (opt-in). GORM tag: `gorm:"default:false"`. +- Only inbound messages (`entities.MessageStatusReceived`) may unarchive a thread. +- Wrap all Go errors with `github.com/palantir/stacktrace` (never return bare errors). +- Go formatting via `go-fumpt` (pre-commit). Run `go test ./...` from `api/`. +- After changing Swagger annotations run, in `api/`: `swag init --requiredByDefault --parseDependency --parseInternal`. +- After Swagger regen, regenerate web models in `web/`: `pnpm api:models`. +- Web: no semicolons, single quotes, 2-space indent (Prettier/ESLint). Lint via `pnpm lint`. +- The `docs/` directory is gitignored in this repo; this plan and its spec were committed with `git add -f`. Do NOT `-f` add source files — only the plan/spec docs need force-add. + +## File Structure + +- `api/pkg/entities/phone.go` — add `UnarchiveThread` field to `Phone`. +- `api/pkg/events/message_phone_received_event.go` — add `UnarchiveThread` to `MessagePhoneReceivedPayload`. +- `api/pkg/services/message_service.go` — populate `UnarchiveThread` on the received-event payload from the loaded phone. +- `api/pkg/services/message_thread_service.go` — add `UnarchiveThread` to `MessageThreadUpdateParams`; add pure helper `shouldUnarchive`; apply it in `UpdateThread`. +- `api/pkg/services/message_thread_service_test.go` — NEW unit tests for `shouldUnarchive`. +- `api/pkg/listeners/message_thread_listener.go` — pass `payload.UnarchiveThread` into params in `OnMessagePhoneReceived`. +- `api/pkg/requests/phone_update_request.go` — add `UnarchiveThread *bool` request field + partial-update mapping. +- `api/pkg/services/phone_service.go` — add `UnarchiveThread *bool` to `PhoneUpsertParams`; apply in `update`. +- `web/app/stores/phones.ts` — send `unarchive_thread` in `updatePhone` PUT body. +- `web/app/pages/settings/index.vue` — add a per-phone toggle bound to `activePhone.unarchive_thread`. +- `web/shared/types/api.ts` — regenerated (not hand-edited). +- `tests/unarchive_thread_integration_test.go` — NEW end-to-end integration test (package `tests`, runs against the live Docker stack). + +--- + +### Task 1: Add `UnarchiveThread` field to the Phone entity + +**Files:** +- Modify: `api/pkg/entities/phone.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `entities.Phone.UnarchiveThread bool` (JSON `unarchive_thread`). + +- [ ] **Step 1: Add the field** + +In `api/pkg/entities/phone.go`, inside the `Phone` struct, add the field just below `MissedCallAutoReply` (keep the existing blank-line grouping style): + +```go + 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"` +``` + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: no output, exit code 0. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/entities/phone.go +git commit -m "feat(api): add UnarchiveThread setting to Phone entity" +``` + +--- + +### Task 2: Add `UnarchiveThread` to the received-message event payload + +**Files:** +- Modify: `api/pkg/events/message_phone_received_event.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `events.MessagePhoneReceivedPayload.UnarchiveThread bool` (JSON `unarchive_thread`). + +- [ ] **Step 1: Add the field** + +In `api/pkg/events/message_phone_received_event.go`, add the field to the `MessagePhoneReceivedPayload` struct (after `Attachments`): + +```go +type MessagePhoneReceivedPayload struct { + MessageID uuid.UUID `json:"message_id"` + UserID entities.UserID `json:"user_id"` + Owner string `json:"owner"` + Encrypted bool `json:"encrypted"` + Contact string `json:"contact"` + Timestamp time.Time `json:"timestamp"` + Content string `json:"content"` + SIM entities.SIM `json:"sim"` + Attachments []string `json:"attachments"` + UnarchiveThread bool `json:"unarchive_thread"` +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: exit code 0. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/events/message_phone_received_event.go +git commit -m "feat(api): add UnarchiveThread to MessagePhoneReceivedPayload" +``` + +--- + +### Task 3: Populate `UnarchiveThread` on the payload in ReceiveMessage + +**Files:** +- Modify: `api/pkg/services/message_service.go` (function `ReceiveMessage`, around lines 333-362) + +**Interfaces:** +- Consumes: `entities.Phone.UnarchiveThread` (Task 1); `events.MessagePhoneReceivedPayload.UnarchiveThread` (Task 2); existing `service.phoneService.Load(ctx, userID, ownerE164 string) (*entities.Phone, error)`. +- Produces: received events now carry the phone's `UnarchiveThread` value. + +- [ ] **Step 1: Load the phone and set the flag** + +In `ReceiveMessage`, the owner E.164 string is `phonenumbers.Format(¶ms.Owner, phonenumbers.E164)`. Replace the `eventPayload := events.MessagePhoneReceivedPayload{...}` block so the owner string is computed once and the phone setting is looked up. Insert BEFORE the `eventPayload :=` assignment: + +```go + owner := phonenumbers.Format(¶ms.Owner, phonenumbers.E164) + + unarchiveThread := false + phone, err := service.phoneService.Load(ctx, params.UserID, owner) + if err != nil { + ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot load phone [%s] for user [%s] to resolve UnarchiveThread; defaulting to false", owner, params.UserID))) + } else { + unarchiveThread = phone.UnarchiveThread + } +``` + +Then update the payload literal to reuse `owner` and set the flag: + +```go + eventPayload := events.MessagePhoneReceivedPayload{ + MessageID: messageID, + UserID: params.UserID, + Encrypted: params.Encrypted, + Owner: owner, + Contact: params.Contact, + Timestamp: params.Timestamp, + Content: params.Content, + SIM: params.SIM, + Attachments: attachmentURLs, + UnarchiveThread: unarchiveThread, + } +``` + +Note: `err` is already declared earlier in `ReceiveMessage` (from the attachments upload), so use `=` not `:=` for the phone load, as shown. Confirm `stacktrace` is already imported in this file (it is). + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: exit code 0. If you get "err redeclared" or "err not used", ensure the phone load uses `phone, err = ...` on its own line (not `:=`) and that `phone` is newly declared with `:=` — since `phone` is new and `err` exists, `phone, err := ...` is correct Go (at least one new var on the left). Prefer `phone, err := service.phoneService.Load(...)`. + +Correction to Step 1: use `phone, err := service.phoneService.Load(ctx, params.UserID, owner)` (mixed assignment is valid because `phone` is new). Keep the rest as written. + +- [ ] **Step 3: Run existing message service tests** + +Run (from `api/`): `go test ./pkg/services/ -run TestMessageService -v` +Expected: PASS (these are pure helper tests unaffected by this change). Also run `go build ./...` again to be safe. + +- [ ] **Step 4: Commit** + +```bash +git add api/pkg/services/message_service.go +git commit -m "feat(api): populate UnarchiveThread from phone on received event" +``` + +--- + +### Task 4: Thread unarchive decision helper + wiring (TDD) + +**Files:** +- Modify: `api/pkg/services/message_thread_service.go` +- Create: `api/pkg/services/message_thread_service_test.go` + +**Interfaces:** +- Consumes: `entities.MessageThread.IsArchived`; `entities.MessageStatusReceived`; `MessageThreadUpdateParams`. +- Produces: + - `MessageThreadUpdateParams.UnarchiveThread bool` + - `func (service *MessageThreadService) shouldUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool` + +- [ ] **Step 1: Write the failing test** + +Create `api/pkg/services/message_thread_service_test.go`: + +```go +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestShouldUnarchive(t *testing.T) { + service := &MessageThreadService{} + + archived := &entities.MessageThread{IsArchived: true} + notArchived := &entities.MessageThread{IsArchived: false} + + received := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: true} + receivedFlagOff := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: false} + sentFlagOn := MessageThreadUpdateParams{Status: entities.MessageStatusSent, UnarchiveThread: true} + + assert.True(t, service.shouldUnarchive(archived, received), "archived + inbound + flag on -> unarchive") + assert.False(t, service.shouldUnarchive(archived, receivedFlagOff), "flag off -> no unarchive") + assert.False(t, service.shouldUnarchive(archived, sentFlagOn), "outbound status -> no unarchive") + assert.False(t, service.shouldUnarchive(notArchived, received), "already unarchived -> no change") +} +``` + +Note: confirm `entities.MessageStatusSent` exists (it does — used in listeners). If not, substitute any non-received status constant such as `entities.MessageStatusPending`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run (from `api/`): `go test ./pkg/services/ -run TestShouldUnarchive -v` +Expected: FAIL — compile error `service.shouldUnarchive undefined` and `params.UnarchiveThread` unknown field. + +- [ ] **Step 3: Add the param field and the helper** + +In `api/pkg/services/message_thread_service.go`, add the field to `MessageThreadUpdateParams`: + +```go +type MessageThreadUpdateParams struct { + Owner string + Status entities.MessageStatus + Contact string + Content string + UserID entities.UserID + MessageID uuid.UUID + Timestamp time.Time + UnarchiveThread bool +} +``` + +Then add the pure helper (place it directly above `UpdateThread`): + +```go +// shouldUnarchive reports whether an archived thread should be moved back to +// the inbox because a new inbound message was received and the phone has the +// UnarchiveThread setting enabled. +func (service *MessageThreadService) shouldUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool { + return thread.IsArchived && params.UnarchiveThread && params.Status == entities.MessageStatusReceived +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run (from `api/`): `go test ./pkg/services/ -run TestShouldUnarchive -v` +Expected: PASS. + +- [ ] **Step 5: Apply the decision inside UpdateThread** + +In `UpdateThread`, the thread is updated via `thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)`. Immediately BEFORE the `if err = service.repository.Update(ctx, thread.Update(...))` call, add: + +```go + if service.shouldUnarchive(thread, params) { + thread.UpdateArchive(false) + ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) + } +``` + +`thread.UpdateArchive(false)` mutates the same `thread` pointer that `thread.Update(...)` mutates and persists, so the single `repository.Update` call saves both the new last message and the cleared archive flag. Do not add a second Update call. + +- [ ] **Step 6: Run the full services test package** + +Run (from `api/`): `go test ./pkg/services/ -v` +Expected: PASS (including `TestShouldUnarchive` and existing `TestMessageService*`). + +- [ ] **Step 7: Commit** + +```bash +git add api/pkg/services/message_thread_service.go api/pkg/services/message_thread_service_test.go +git commit -m "feat(api): unarchive thread on inbound message when enabled" +``` + +--- + +### Task 5: Pass the flag through the received-message listener + +**Files:** +- Modify: `api/pkg/listeners/message_thread_listener.go` (function `OnMessagePhoneReceived`) + +**Interfaces:** +- Consumes: `events.MessagePhoneReceivedPayload.UnarchiveThread` (Task 2); `MessageThreadUpdateParams.UnarchiveThread` (Task 4). +- Produces: inbound received events now request unarchiving. + +- [ ] **Step 1: Set the field on the params** + +In `OnMessagePhoneReceived`, update the `updateParams := services.MessageThreadUpdateParams{...}` literal to include the flag: + +```go + updateParams := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + Timestamp: payload.Timestamp, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Content: payload.Content, + MessageID: payload.MessageID, + UnarchiveThread: payload.UnarchiveThread, + } +``` + +Do NOT set `UnarchiveThread` on any other handler in this file (only inbound received messages unarchive). + +- [ ] **Step 2: Verify it compiles and tests pass** + +Run (from `api/`): `go build ./...` then `go test ./pkg/services/... ./pkg/listeners/...` +Expected: exit code 0 / PASS. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/listeners/message_thread_listener.go +git commit -m "feat(api): forward UnarchiveThread flag from received event to thread update" +``` + +--- + +### Task 6: Accept `unarchive_thread` in the phone upsert request/service + +**Files:** +- Modify: `api/pkg/requests/phone_update_request.go` +- Modify: `api/pkg/services/phone_service.go` (`PhoneUpsertParams` struct and `update` method) + +**Interfaces:** +- Consumes: `entities.Phone.UnarchiveThread` (Task 1). +- Produces: `PhoneUpsertParams.UnarchiveThread *bool`; PUT `/v1/phones` persists the setting. + +- [ ] **Step 1: Add the request field** + +In `api/pkg/requests/phone_update_request.go`, add to the `PhoneUpsert` struct (after `MissedCallAutoReply`): + +```go + 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"` +``` + +- [ ] **Step 2: Map it as a partial-update field in ToUpsertParams** + +Still in `phone_update_request.go`, inside `ToUpsertParams`, after the `maxSendAttempts` block, add the presence-detected mapping (mirrors the existing pattern): + +```go + var unarchiveThread *bool + if _, exists := fields["unarchive_thread"]; exists { + unarchiveThread = &input.UnarchiveThread + } +``` + +Then add `UnarchiveThread: unarchiveThread,` to the returned `&services.PhoneUpsertParams{...}` literal. + +- [ ] **Step 3: Add the field to PhoneUpsertParams and apply it** + +In `api/pkg/services/phone_service.go`, add to `PhoneUpsertParams`: + +```go + UnarchiveThread *bool +``` + +In the `update` method, before `phone.SIM = params.SIM`, add: + +```go + if params.UnarchiveThread != nil { + phone.UnarchiveThread = *params.UnarchiveThread + } +``` + +- [ ] **Step 4: Verify build and tests** + +Run (from `api/`): `go build ./...` then `go test ./pkg/...` +Expected: exit code 0 / PASS. + +- [ ] **Step 5: Regenerate Swagger docs** + +Run (from `api/`): `swag init --requiredByDefault --parseDependency --parseInternal` +Expected: regenerates `api/docs/*` including the new `unarchive_thread` fields. If `swag` is not installed: `go install github.com/swaggo/swag/cmd/swag@latest` then re-run. + +- [ ] **Step 6: Commit** + +```bash +git add api/pkg/requests/phone_update_request.go api/pkg/services/phone_service.go api/docs +git commit -m "feat(api): accept unarchive_thread in phone upsert request" +``` + +--- + +### Task 7: Regenerate web API models + +**Files:** +- Modify (generated): `web/shared/types/api.ts` + +**Interfaces:** +- Consumes: regenerated Swagger from Task 6. +- Produces: `EntitiesPhone.unarchive_thread?: boolean` available to the web app. + +- [ ] **Step 1: Install web deps (if not already installed)** + +Run (from `web/`): `pnpm install` +Expected: completes without `ERR_PNPM_IGNORED_BUILDS`. + +- [ ] **Step 2: Regenerate models** + +Run (from `web/`): `pnpm api:models` +Expected: `web/shared/types/api.ts` now includes `unarchive_thread` on the phone type (search the file for `unarchive_thread`). + +- [ ] **Step 3: Verify the field exists** + +Run (from `web/`): `Select-String -Path shared/types/api.ts -Pattern unarchive_thread` +Expected: at least one match. + +- [ ] **Step 4: Commit** + +```bash +git add web/shared/types/api.ts +git commit -m "chore(web): regenerate api models with unarchive_thread" +``` + +--- + +### Task 8: Send `unarchive_thread` in the phones store update + +**Files:** +- Modify: `web/app/stores/phones.ts` (function `updatePhone`, around lines 48-62) + +**Interfaces:** +- Consumes: `EntitiesPhone.unarchive_thread` (Task 7). +- Produces: PUT `/v1/phones` body includes `unarchive_thread`. + +- [ ] **Step 1: Add the field to the PUT body** + +In `updatePhone`, inside the `body` object passed to `apiFetch('/v1/phones', { method: 'PUT', ... })`, add after `message_send_schedule_id`: + +```ts + message_send_schedule_id: phone.message_send_schedule_id ?? null, + unarchive_thread: phone.unarchive_thread ?? false, +``` + +- [ ] **Step 2: Lint** + +Run (from `web/`): `pnpm lint` +Expected: no new errors for `web/app/stores/phones.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add web/app/stores/phones.ts +git commit -m "feat(web): send unarchive_thread in updatePhone" +``` + +--- + +### Task 9: Add the per-phone toggle to the settings UI + +**Files:** +- Modify: `web/app/pages/settings/index.vue` (phone settings card, near the `missed_call_auto_reply` VTextarea around lines 1680-1690) + +**Interfaces:** +- Consumes: `activePhone.unarchive_thread` (Task 7); `updatePhone` store action (Task 8). +- Produces: user-visible toggle that persists via the existing "Update Phone" button. + +- [ ] **Step 1: Add a VSwitch bound to activePhone.unarchive_thread** + +In `web/app/pages/settings/index.vue`, immediately AFTER the closing `/>` of the `missed_call_auto_reply` `` (line ~1690) and before the ``, add: + +```html + +``` + +Note: match the surrounding indentation exactly (this block sits inside the same `` as the textarea). If `activePhone` is typed and `unarchive_thread` is optional, `v-model` still works; the store update coerces with `?? false`. + +- [ ] **Step 2: Lint** + +Run (from `web/`): `pnpm lint` +Expected: no new errors for `settings/index.vue`. Auto-fix formatting if needed with `pnpm lintfix`. + +- [ ] **Step 3: Build the site to confirm the template compiles** + +Run (from `web/`): `pnpm run generate` +Expected: build completes without template/compile errors. (If `generate` is heavy, `pnpm dev` briefly and confirm the settings page renders is an acceptable alternative.) + +- [ ] **Step 4: Commit** + +```bash +git add web/app/pages/settings/index.vue +git commit -m "feat(web): add per-phone unarchive-thread toggle to settings" +``` + +--- + +### Task 10: Full validation + +**Files:** none (verification only). + +- [ ] **Step 1: API full test + build** + +Run (from `api/`): `go test ./...` then `go build ./...` +Expected: all PASS, exit code 0. + +- [ ] **Step 2: Web lint + test** + +Run (from `web/`): `pnpm lint` then `pnpm test` +Expected: PASS. + +- [ ] **Step 3: Manual smoke (optional, if a local stack is available)** + +Start the stack (`docker compose up --build`), open the web settings for a phone, enable "Unarchive thread on new message", save. Archive a thread, then simulate/receive an inbound message for that owner and confirm the thread returns to the inbox. Toggle off and confirm an inbound message leaves the thread archived. + +- [ ] **Step 4: Final commit (only if any fixups were needed)** + +```bash +git add -A +git commit -m "chore: finalize unarchive-thread-on-receive feature" +``` + +--- + +### Task 11: End-to-end integration test + +**Files:** +- Create: `tests/unarchive_thread_integration_test.go` + +**Context:** The `tests/` module is a black-box integration suite (package `tests`) +that runs against the full Docker stack (API on `localhost:8000`, CockroachDB, +Redis, phone emulator). It uses the external `github.com/NdoleStudio/httpsms-go` +client for convenience, but that client does NOT know about the new +`unarchive_thread` field — so this test sets the phone flag with a raw HTTP PUT +to `/v1/phones` and reads threads with raw HTTP GETs. It reuses existing helpers +from `helpers_test.go`: `apiBaseURL`, `userAPIKey`, `newAPIClient`, `setupPhone`, +`randomPhoneNumber`, `randomEncryptionKey`, `pollMessageStatus`. + +**Interfaces:** +- Consumes: the running API with all prior tasks deployed; endpoints + `PUT /v1/phones`, `POST /v1/messages/receive`, `GET /v1/message-threads`, + `PUT /v1/message-threads/{id}`. +- Produces: `TestUnarchiveThreadOnReceive_Enabled` and + `TestUnarchiveThreadOnReceive_Disabled`. + +- [ ] **Step 1: Write the integration test file** + +Create `tests/unarchive_thread_integration_test.go`: + +```go +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + Owner string `json:"owner"` + IsArchived bool `json:"is_archived"` + LastMessageContent *string `json:"last_message_content"` +} + +// setUnarchiveThread flips the per-phone unarchive_thread flag via a raw PUT +// (the httpsms-go client has no field for it). +func setUnarchiveThread(ctx context.Context, t *testing.T, phoneNumber string, enabled bool) { + t.Helper() + + payload := map[string]interface{}{ + "phone_number": phoneNumber, + "sim": "SIM1", + "unarchive_thread": enabled, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/phones", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "set unarchive_thread failed: %s", string(respBody)) +} + +// receiveInbound submits an inbound message as the phone and returns the message ID. +func receiveInbound(ctx context.Context, t *testing.T, phoneAPIKey, from, to, content string, ts time.Time) string { + t.Helper() + + payload := map[string]interface{}{ + "from": from, + "to": to, + "content": content, + "sim": "SIM1", + "timestamp": ts.UTC().Format(time.RFC3339), + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/messages/receive", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", phoneAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "receive failed: %s", string(respBody)) + + var result httpsms.MessageResponse + require.NoError(t, json.Unmarshal(respBody, &result)) + id := result.Data.ID.String() + require.NotEmpty(t, id) + return id +} + +// fetchThreads returns threads for an owner filtered by archived state. +func fetchThreads(ctx context.Context, t *testing.T, owner string, archived bool) []integrationThread { + t.Helper() + + url := fmt.Sprintf("%s/v1/message-threads?owner=%s&is_archived=%t&limit=20", apiBaseURL, owner, archived) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "fetch threads failed: %s", string(respBody)) + + var result struct { + Data []integrationThread `json:"data"` + } + require.NoError(t, json.Unmarshal(respBody, &result)) + return result.Data +} + +func findThreadByContact(threads []integrationThread, contact string) *integrationThread { + for i := range threads { + if threads[i].Contact == contact { + return &threads[i] + } + } + return nil +} + +// waitForThread polls the archived/unarchived thread list until a thread for the +// contact appears (optionally matching a last-message content), then returns it. +func waitForThread(ctx context.Context, t *testing.T, owner, contact string, archived bool, wantContent string, timeout time.Duration) *integrationThread { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + thread := findThreadByContact(fetchThreads(ctx, t, owner, archived), contact) + if thread != nil && (wantContent == "" || (thread.LastMessageContent != nil && *thread.LastMessageContent == wantContent)) { + return thread + } + time.Sleep(500 * time.Millisecond) + } + return nil +} + +// archiveThread archives a thread by ID. +func archiveThread(ctx context.Context, t *testing.T, threadID string) { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{"is_archived": true}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/message-threads/"+threadID, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "archive thread failed: %s", string(respBody)) +} + +func TestUnarchiveThreadOnReceive_Enabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + setUnarchiveThread(ctx, t, phone.PhoneNumber, true) + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + // First inbound message creates the thread. + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + // Archive it. + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message should unarchive it. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + unarchived := waitForThread(ctx, t, phone.PhoneNumber, contact, false, content2, 20*time.Second) + require.NotNil(t, unarchived, "thread was not unarchived after inbound message") + assert.False(t, unarchived.IsArchived) + require.NotNil(t, unarchived.LastMessageContent) + assert.Equal(t, content2, *unarchived.LastMessageContent) +} + +func TestUnarchiveThreadOnReceive_Disabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) // unarchive_thread defaults to false; do not enable it + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message must NOT unarchive it. Sync on the archived thread's + // last_message_content updating to content2 (proves the listener processed it), + // then assert it is still archived. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + stillArchived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, content2, 20*time.Second) + require.NotNil(t, stillArchived, "archived thread did not reflect the second inbound message") + assert.True(t, stillArchived.IsArchived, "thread should remain archived when unarchive_thread is disabled") + + // And it must not have leaked into the unarchived list. + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact), + "thread should not appear in the unarchived list") +} +``` + +- [ ] **Step 2: Build the test module (compile check without a running stack)** + +Run (from `tests/`): `go vet ./...` +Expected: exit code 0 (compiles). If `httpsms.MessageResponse` field access differs, adjust to match the version pinned in `tests/go.mod` (the existing `integration_test.go` already uses `httpsms.MessageResponse` and `result.Data.ID`). + +- [ ] **Step 3: Run the full integration suite against the Docker stack** + +Run (from `tests/`), following the README one-liner: + +```bash +bash generate-firebase-credentials.sh +export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) +docker compose up -d --build --wait +docker compose wait seed +sleep 2 +go test -v -timeout 180s -run TestUnarchiveThreadOnReceive ./... +docker compose down -v +``` + +On Windows without bash/jq available, run the integration suite via the CI +workflow (`.github/workflows/integration-test.yml`) or a Linux/WSL shell; the +Step 2 `go vet` compile check is the local gate. + +Expected: both `TestUnarchiveThreadOnReceive_Enabled` and +`TestUnarchiveThreadOnReceive_Disabled` PASS. + +- [ ] **Step 4: Update the test coverage checklist in the README** + +In `tests/README.md`, under "## Test Coverage", add: + +```markdown +- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled +``` + +- [ ] **Step 5: Commit** + +```bash +git add tests/unarchive_thread_integration_test.go tests/README.md +git commit -m "test(integration): verify unarchive thread on receive" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Data model field -> Task 1. ✓ +- Event payload field -> Task 2. ✓ +- Populate flag in ReceiveMessage via phoneService.Load -> Task 3 (with fallback to false on load error). ✓ +- Thread trigger (inbound-only, archived-only, same Update call) -> Task 4. ✓ +- Listener wiring, inbound-only -> Task 5. ✓ +- Phone upsert request + params partial update -> Task 6. ✓ +- Swagger regen -> Task 6 Step 5. ✓ +- Web: store PUT body -> Task 8; settings toggle -> Task 9; model regen -> Task 7. ✓ +- Testing (pure helper unit test, go test, web lint/test) -> Tasks 4 & 10. ✓ +- Integration test (live-stack E2E, enabled + disabled) -> Task 11. ✓ +- Out of scope (Android, global/per-thread config, auto-archive) -> not implemented. ✓ + +**Placeholder scan:** No TBD/TODO; all code steps include concrete code and exact commands. + +**Type consistency:** `UnarchiveThread` (Go) / `unarchive_thread` (JSON) used identically across entity, event, params, request, store, and template. Helper `shouldUnarchive(thread, params)` signature matches its test and its call site. `phoneService.Load(ctx, userID, ownerE164)` matches existing usage in `RespondToMissedCall`. diff --git a/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md b/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md new file mode 100644 index 000000000..6023fdce5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md @@ -0,0 +1,37 @@ +# Message Thread Archive UI Design + +## Goal + +Improve message-thread selection styling and keep archive actions within the +current thread-list filter. + +## Active Thread Styling + +Add `color="primary"` to every thread `v-list-item` in +`web/app/components/MessageThread.vue`. Vuetify will apply the primary color +when the route-backed item is active without changing inactive items. + +## Archive and Unarchive Behavior + +`useThreadsStore.updateThread` will continue to send the existing +`PUT /v1/message-threads/:id` request. After a successful response, it will: + +1. Preserve the current `archivedThreads` filter. +2. Remove the moved thread from the currently displayed thread collection. +3. Clear the selected thread ID. +4. Show a success notification containing `Archived` or `Unarchived`. + +The thread page will route to `/threads` after the store update succeeds. +Archiving therefore returns to the unarchived list, while unarchiving returns +to the archived list. Neither action switches the list filter. + +## Error Handling + +API errors will continue to propagate from `apiFetch`. Local thread state, +notifications, and navigation will only change after a successful response. + +## Validation + +The web package has no configured automated tests. Validate the changed files +with the existing lint commands and run the existing static generation command +to confirm the production frontend builds successfully. diff --git a/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md b/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md new file mode 100644 index 000000000..948490044 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md @@ -0,0 +1,118 @@ +# Auto-unarchive Message Threads on Inbound Message (per-phone) + +- Date: 2026-07-18 +- Status: Approved (design) +- Scope: `api/` (Go backend) and `web/` (Nuxt frontend). Android app not touched. + +## Problem + +When a message thread is archived it stays archived even if the contact sends a +new inbound message. Users want an archived thread to move back to the +unarchived (inbox) state when a new message is received. This behaviour must be +configurable. + +## Decisions + +- **Granularity:** configurable per **phone number** (per `Phone`), consistent + with other per-phone settings such as `MessagesPerMinute` and + `MissedCallAutoReply`. +- **Trigger:** only **inbound** messages received from the contact + (`entities.MessageStatusReceived`). Outbound activity (sent/sending/delivered/ + scheduled/expired) does not unarchive a thread. +- **Default:** **disabled** (opt-in). Existing and new phones default to `false`. +- **Field name:** `UnarchiveThread` (JSON `unarchive_thread`). +- **Data flow:** the flag travels on the received-message event payload + (Option B), so `MessageThreadService` gains **no** new repository dependency. + +## Design + +### 1. Data model — `api/pkg/entities/phone.go` + +Add a boolean to the `Phone` entity: + +```go +UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` +``` + +GORM auto-migrates the column. Existing rows default to `false`. + +### 2. Event payload — `api/pkg/events/message_phone_received_event.go` + +Add the flag to `MessagePhoneReceivedPayload`: + +```go +UnarchiveThread bool `json:"unarchive_thread"` +``` + +### 3. Populate the flag — `api/pkg/services/message_service.go` + +In `ReceiveMessage`, before building `eventPayload`, load the owner phone using +the already-injected `phoneService` (same pattern as `RespondToMissedCall`, +which calls `service.phoneService.Load(ctx, payload.UserID, payload.Owner)`) and +copy `phone.UnarchiveThread` onto the payload. + +- The owner E.164 string is `phonenumbers.Format(¶ms.Owner, phonenumbers.E164)`. +- If the phone lookup fails, log and default `UnarchiveThread` to `false`; do not + fail message reception because of a missing/failed phone lookup. + +### 4. Thread service trigger — `api/pkg/services/message_thread_service.go` + +- Add `UnarchiveThread bool` to `MessageThreadUpdateParams`. +- In `UpdateThread`, after the thread is loaded, if the thread `IsArchived` is + true **and** `params.Status == entities.MessageStatusReceived` **and** + `params.UnarchiveThread` is true, set the thread's `IsArchived = false` as part + of the same update that persists the new last message. Reuse + `thread.UpdateArchive(false)` or set the field directly before + `repository.Update`. +- The existing early-return guards (out-of-order timestamp, already-delivered) + must not skip the unarchive when a genuinely new inbound message arrives; the + unarchive is applied on the same path as the normal thread update. + +### 5. Listener — `api/pkg/listeners/message_thread_listener.go` + +In `OnMessagePhoneReceived`, set `UnarchiveThread: payload.UnarchiveThread` on +the `MessageThreadUpdateParams`. No other listener passes this flag (only inbound +received messages should unarchive). + +### 6. Request/params — phone upsert + +- `api/pkg/requests/phone_update_request.go`: add `UnarchiveThread *bool` + (pointer, applied only when present in the JSON body — same partial-update + pattern as `MessagesPerMinute`, using the `fields` map check on key + `unarchive_thread`). +- `api/pkg/services/phone_service.go` `PhoneUpsertParams`: add + `UnarchiveThread *bool` and apply it in the upsert when non-nil. + +### 7. Web frontend — `web/` + +- `web/app/stores/phones.ts` `updatePhone`: include + `unarchive_thread: phone.unarchive_thread` in the PUT body. +- `web/app/pages/settings/index.vue`: add a toggle (switch/checkbox) for the + per-phone "Unarchive thread when a new message is received" setting, alongside + the existing per-phone settings. +- Regenerate TypeScript API models with `pnpm api:models` after the Swagger spec + is regenerated so `EntitiesPhone` in `web/shared/types/api.ts` includes + `unarchive_thread`. + +### 8. Swagger + +Run `swag init --requiredByDefault --parseDependency --parseInternal` in `api/` +after adding the annotation-affecting struct field so the generated docs and the +web model regeneration stay in sync. + +## Testing + +- **API unit test** (`message_thread_service` test): archived thread + inbound + received message + `UnarchiveThread=true` -> thread becomes unarchived; + `UnarchiveThread=false` -> stays archived; outbound status with the flag true + -> stays archived. +- **API**: `MessageService.ReceiveMessage` populates `UnarchiveThread` from the + loaded phone; a failed phone lookup yields `false` and does not error. +- Run `go test ./...` in `api/`. +- **Web**: `pnpm lint` and `pnpm test` in `web/`. + +## Out of scope + +- Android app settings UI. +- Global (user-level) or per-thread configuration. +- Auto-archiving behaviour (this design only unarchives). diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 56e1d7c0f..029599180 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -94,6 +94,7 @@ function onInstallApp() { diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 541a0d3a5..1fd11eae1 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -148,9 +148,7 @@ async function archiveThread() { threadId: threadsStore.currentThread!.id, isArchived: true, }) - setTimeout(() => { - selectedMenuItem.value = -1 - }, 1000) + await router.push('/threads') } async function unArchiveThread() { @@ -158,9 +156,7 @@ async function unArchiveThread() { threadId: threadsStore.currentThread!.id, isArchived: false, }) - setTimeout(() => { - selectedMenuItem.value = -1 - }, 1000) + await router.push('/threads') } async function resendMessage(message: EntitiesMessage) { diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 7312153ce..aef2b2947 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -83,8 +83,14 @@ export const useThreadsStore = defineStore('threads', () => { method: 'PUT', body: { is_archived: payload.isArchived }, }) - archivedThreads.value = payload.isArchived - await loadThreads() + threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, + ) + threadId.value = null + notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', + }) } async function deleteThread(id: string) { From 8010ce30a27e8f78ec2d5c41b7df80e905313bc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 18 Jul 2026 11:48:04 +0300 Subject: [PATCH 02/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 13/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 14/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) }} + + + + +