From eb90685be6505064df8ff239c077391e806cf060 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 20:59:49 +0300 Subject: [PATCH 01/24] docs: add contacts feature implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577 --- .../plans/2026-07-19-contacts-feature.md | 3025 +++++++++++++++++ 1 file changed, 3025 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-contacts-feature.md diff --git a/docs/superpowers/plans/2026-07-19-contacts-feature.md b/docs/superpowers/plans/2026-07-19-contacts-feature.md new file mode 100644 index 000000000..8a72fe407 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-contacts-feature.md @@ -0,0 +1,3025 @@ +# Contacts Feature 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:** Add a Contacts feature (name, emails, phone numbers, free-form properties) with full CRUD + CSV import, and display the resolved contact name instead of the raw phone number on the threads page, using as few DB queries as possible. + +**Architecture:** New `Contact` entity + layered backend (handler → service → repository → validator → requests/responses) mirroring existing patterns. Thread-name resolution is opt-in per request and served from a per-user `phone_number → *Contact` map cached in `cache.Cache`, so the common path adds zero DB queries and a name change is a single-row `UPDATE` + cache invalidation. Frontend adds a Pinia store and a Plunk-style Contacts page (Nuxt 4 SPA). + +**Tech Stack:** Go (Fiber v3, GORM v1.31.2, lib/pq, nyaruka/phonenumbers, jszwec/csvutil), Nuxt 4 + Pinia + Vuetify 4, Swagger (`swag`), swagger-typescript-api. + +## Global Constraints + +- Source of truth: `docs/superpowers/specs/2026-07-19-contacts-feature-design.md`. Follow it exactly. +- Run Go tests with `go test -vet=off ./...` from `api/` (Go 1.26 vet rejects the repo's `stacktrace.Propagate(err, fmt.Sprintf(...))` pattern). +- Wrap all Go errors with `github.com/NdoleStudio/stacktrace` (`Propagatef` / `PropagateWithCodef`) — never return bare errors. +- All GORM queries use `repository.db.WithContext(ctx)`. No raw SQL. +- Register Fiber routes via the `h.register(router, fiber.MethodX, path, middlewares, route)` helper (Fiber v3), not `Get`/`Post` directly. +- Contacts are global to the user account. **No** uniqueness constraint on phone numbers; **no** `contact_phone_numbers` lookup table. +- On phone-number collision across contacts, the **most recently updated** contact wins for display. +- Thread-name resolution is **opt-in** via `?contacts=true` (default off). +- Create endpoint accepts one or many; batch capped at **≤ 1000** contacts; cache invalidated **once** after the batch commits. +- CSV import is **CSV only** — Excel/XLSX is not supported for contacts. Max 500 KB, ≤ 1000 rows. +- `pq.StringArray` with `gorm:"type:text[]" swaggertype:"array,string"` for array fields (convention from `phone_api_key.go`/`webhook.go`). +- Web conventions: every `VDialog` has `opacity="0.9"` and a Close button with `color="warning"`; hyperlinks get `text-decoration-none hover:text-decoration-underline`; destructure filters 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..cca405dc5 100644 --- a/web/app/composables/useFilters.ts +++ b/web/app/composables/useFilters.ts @@ -7,6 +7,7 @@ import { formatBillingPeriod, formatBillingPeriodDateOrdinal, humanizeTime, + startsWithLetter, } from '../utils/filters' import { capitalize } from '../utils/capitalize' @@ -20,6 +21,7 @@ export function useFilters() { formatBillingPeriod, formatBillingPeriodDateOrdinal, humanizeTime, + startsWithLetter, capitalize, } } diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 73632a64c..68882d461 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -19,7 +19,6 @@ import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { isValidPhoneNumber } from 'libphonenumber-js' import type { EntitiesMessage } from '~~/shared/types/api' -import { startsWithLetter } from '~/utils/filters' definePageMeta({ middleware: ['auth'], @@ -33,7 +32,7 @@ const route = useRoute() const router = useRouter() const config = useRuntimeConfig() const { lgAndUp, mdAndDown, mdAndUp } = useDisplay() -const { formatPhoneNumber } = useFilters() +const { formatPhoneNumber, startsWithLetter } = useFilters() const notificationsStore = useNotificationsStore() const authStore = useAuthStore() const phonesStore = usePhonesStore() @@ -104,6 +103,14 @@ function formatAttachmentName(url: string): string { return url } +function currentThreadContactTitle(): string { + const thread = threadsStore.currentThread + if (!thread) return '' + return ( + thread.contact_details?.name?.trim() || formatPhoneNumber(thread.contact) + ) +} + function scrollToElement() { const el = messageBody.value if (el) { @@ -268,7 +275,7 @@ onBeforeUnmount(() => { - {{ formatPhoneNumber(threadsStore.currentThread.contact) }} + {{ currentThreadContactTitle() }}