From 78e1d183a13f5e43143fba2c6dae1d883c05dcc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 20:13:15 +0300 Subject: [PATCH 01/81] 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) }} + + + + +