Skip to content

feat: add contacts management and thread resolution#957

Open
AchoArnold wants to merge 24 commits into
mainfrom
feature/contacts
Open

feat: add contacts management and thread resolution#957
AchoArnold wants to merge 24 commits into
mainfrom
feature/contacts

Conversation

@AchoArnold

Copy link
Copy Markdown
Member

Summary

  • add contact CRUD, one-or-many JSON creation, CSV import, validation, Swagger, and cached phone-to-contact resolution
  • add the Contacts management page with server-side search/pagination and add/edit/delete/import dialogs
  • display resolved contact names in message threads and add Contacts navigation

Testing

  • cd api && go test -vet=off ./...
  • cd api && go build ./...
  • cd web && pnpm lint
  • cd web && pnpm generate

AchoArnold and others added 24 commits July 19, 2026 20:13
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move sanitizeUniqueStrings into the shared requests helper.
Preserve first-seen ordering after normalization.
Add a direct ToContacts nil-properties regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Allow .csv extension only; accept common browser MIME fallbacks for uploads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add contacts query parsing and optional ContactDetails enrichment through a contact map provider.

DI passes nil until Task 8 wires ContactService.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add ContactHandler with authenticated /v1/contacts routes:
  GET/POST /v1/contacts, POST /v1/contacts/upload,
  PUT/DELETE /v1/contacts/:contactID.
- Load user-scoped contact before Update/Delete so a missing or
  cross-user contact returns 404 (avoids the repo's zero-row Delete
  returning 204 while advertising 404).
- Support one-and-many JSON create via ContactStoreRequest and
  reuse ContactService.CreateMany so cache invalidation stays
  single-shot for Store and CSV upload alike.
- Add DI getters ContactRepository, ContactService,
  ContactHandlerValidator, ContactHandler and RegisterContactRoutes;
  register the routes alongside RegisterMessageThreadRoutes.
- Finalise Task 7 by injecting container.ContactService() into
  MessageThreadService, replacing the temporary nil dependency.
- Cover all five routes with real Fiber requests plus a shared
  ContactRepository fake that verifies service/repository effects,
  and pin the DI wiring with a compile-time test that constructs
  MessageThreadService with the ContactService.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make emails/properties optional in contact request DTOs.
Regenerate Swagger docs and add request JSON regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ialogs

Add the /contacts page grounded in the existing Vuetify theme.

Header uses text-display-large; debounced search drives the API ?query= param.

VDataTable columns: Name, Phone Numbers, Emails, Created, Updated, Actions.

Rows show avatar initials, phone chips and relative timestamps via humanizeTime.

Add/edit/delete/import-CSV dialogs use opacity 0.9 and warning-colored Close controls.

Add/Edit has repeatable phone numbers, emails and free-form properties, preserved on edit.

CSV import surfaces row-indexed API validation errors inline, not only via a toast.

loadContacts forwards the trimmed search value as the query param, keeping Task 11's contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve contact details across mark-read updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the contact repository/service/handler so GET /v1/contacts returns
a top-level total count for the same user/query filter, independent of
skip/limit, enabling true server-side pagination.

- add ContactRepository.Count reusing a shared scopedContactQuery helper
  so Index and Count filters can never drift; Count ignores limit/offset
- add ContactService.Count and handler responseOKWithTotal; Index returns total
- add Total to responses.ContactsResponse and regenerate Swagger
- sanitize parsed CSV rows before validation so CSV and JSON accept the
  same phone/email formats; drop the now-redundant re-sanitize on upload
- assert Scan error behaviour instead of the unexported stacktrace type
- tests for count filter parity, total propagation, and CSV sanitization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- store total now reflects the server count; loadContacts accepts skip and
  limit and remembers the current window so mutation refreshes stay in place
- contacts page uses VDataTableServer with items-length bound to the server
  total; page/size changes fetch the correct skip/limit and a debounced
  query resets to page 1 without duplicate requests
- delete bumps the load generation so an in-flight load cannot resurrect a
  deleted contact, without leaving loading stuck or hiding delete errors
- remove the dead filteredContacts computed and add defensive null
  coalescing for emails/phone_numbers in the table display
- regenerate API models with the new contacts total field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove misleading sort affordances from the contacts table.

Keep the subtitle aligned with the active search state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 50 minor

Alerts:
⚠ 50 issues (≤ 0 issues of at least minor severity)

Results:
50 new issues

Category Results
CodeStyle 50 minor

View in Codacy

🟢 Metrics 518 complexity · 201 duplication

Metric Results
Complexity 518
Duplication 201

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a full contacts management feature: a new Contact entity with CRUD endpoints, CSV import, cached phone-to-contact name resolution in message threads, and a server-side-paginated contacts page in the web UI.

  • Backend: adds ContactRepository, ContactService (with a 24 h per-user cache for the phone→contact map), ContactHandlerValidator (E.164 phone + RFC 5322 email validation, 1000-row batch cap, 500 KB CSV cap), and 5 REST endpoints under /v1/contacts. The MessageThreadService.GetThreads now accepts a WithContacts flag to resolve display names in a single cached lookup.
  • Frontend: new contacts Pinia store with generation-based stale-response cancellation and a full pages/contacts/index.vue with add/edit/delete/import dialogs, debounced server-side search, and VDataTableServer pagination. Message thread list and header show resolved contact names when available.

Confidence Score: 3/5

Safe to merge with caution — one data-retention gap needs to be addressed before this goes to production for any deployment that supports account deletion.

The contacts feature is well-structured and thoroughly tested, but DeleteAllForUser is wired to the repository interface and never called on account deletion, unlike every other entity in the codebase. Users who delete their accounts will have their contacts silently retained in the database. Additionally, the ILIKE search passes raw user input as a LIKE pattern without escaping % and _, so a search for _ returns all contacts.

api/pkg/repositories/contact_repository.go and the missing contacts listener file (no listener for UserAccountDeleted was added); api/pkg/repositories/gorm_contact_repository.go for the unescaped LIKE wildcards in the search query.

Important Files Changed

Filename Overview
api/pkg/repositories/contact_repository.go ContactRepository interface defines DeleteAllForUser but no listener is wired to call it on UserAccountDeleted, leaving orphaned contact rows when a user is deleted.
api/pkg/repositories/gorm_contact_repository.go GORM implementation of ContactRepository; ILIKE search passes raw user query as a LIKE pattern without escaping %/_ wildcards, causing overly broad matches for those characters.
api/pkg/services/contact_service.go ContactService with cache-backed phone→contact map; GetContactMap calls FetchAll with no row limit, which could cause large memory allocations for users with many contacts.
api/pkg/handlers/contact_handler.go HTTP handler for contact CRUD and CSV upload; correctly validates UUIDs, scopes all operations to the authenticated user, and returns 404 before delete rather than silently succeeding on a 0-row DELETE.
api/pkg/validators/contact_handler_validator.go Validates JSON and CSV create/update contacts; phone numbers validated with libphonenumber, emails with net/mail, CSV size capped at 500 KB, batch capped at 1000 rows.
api/pkg/entities/contact.go New Contact entity with JSONB ContactProperties custom scanner/valuer and pq.StringArray for emails/phone_numbers; model is well-structured with correct GORM tags.
api/pkg/services/message_thread_service.go Adds optional contact resolution to GetThreads via a contactMapProvider interface and the new WithContacts flag; contact lookup errors are logged but don't fail the request.
api/pkg/requests/contact_store.go ContactStoreRequest with custom UnmarshalJSON supporting both JSON array and wrapped-object formats; SanitizeContactItem deduplicates and normalizes phone/email values.
web/app/stores/contacts.ts Pinia contacts store with generation-based stale-response cancellation, server-side pagination state tracking, and local optimistic delete.
web/app/pages/contacts/index.vue Full contacts management page with server-side search/pagination via VDataTableServer, add/edit/delete/import dialogs, debounced search, and inline API error display.
api/pkg/di/container.go Wires ContactService, ContactRepository, ContactHandler, and ContactHandlerValidator into the DI container; registers AutoMigrate for the contacts table and mounts routes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant ContactHandler
    participant ContactHandlerValidator
    participant ContactService
    participant ContactRepository
    participant Cache

    Client->>ContactHandler: POST /v1/contacts (JSON array or object)
    ContactHandler->>ContactHandlerValidator: ValidateStore(request)
    ContactHandlerValidator-->>ContactHandler: url.Values (errors)
    ContactHandler->>ContactService: CreateMany(userID, contacts)
    ContactService->>ContactRepository: Store(contacts)
    ContactRepository-->>ContactService: nil
    ContactService->>Cache: Set(key, empty, 24h) [invalidate]
    ContactService-->>ContactHandler: nil
    ContactHandler-->>Client: 201 Created

    Client->>ContactHandler: "GET /v1/threads?contacts=true"
    ContactHandler->>MessageThreadService: "GetThreads(WithContacts=true)"
    MessageThreadService->>ContactService: GetContactMap(userID)
    ContactService->>Cache: Get(key)
    alt cache hit
        Cache-->>ContactService: JSON map
        ContactService-->>MessageThreadService: "map[phone]*Contact"
    else cache miss or invalidated
        Cache-->>ContactService: empty or error
        ContactService->>ContactRepository: FetchAll(userID)
        ContactRepository-->>ContactService: []Contact
        ContactService->>Cache: Set(key, encodedMap, 24h)
        ContactService-->>MessageThreadService: "map[phone]*Contact"
    end
    MessageThreadService-->>ContactHandler: []MessageThread with ContactDetails
    ContactHandler-->>Client: 200 OK
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant ContactHandler
    participant ContactHandlerValidator
    participant ContactService
    participant ContactRepository
    participant Cache

    Client->>ContactHandler: POST /v1/contacts (JSON array or object)
    ContactHandler->>ContactHandlerValidator: ValidateStore(request)
    ContactHandlerValidator-->>ContactHandler: url.Values (errors)
    ContactHandler->>ContactService: CreateMany(userID, contacts)
    ContactService->>ContactRepository: Store(contacts)
    ContactRepository-->>ContactService: nil
    ContactService->>Cache: Set(key, empty, 24h) [invalidate]
    ContactService-->>ContactHandler: nil
    ContactHandler-->>Client: 201 Created

    Client->>ContactHandler: "GET /v1/threads?contacts=true"
    ContactHandler->>MessageThreadService: "GetThreads(WithContacts=true)"
    MessageThreadService->>ContactService: GetContactMap(userID)
    ContactService->>Cache: Get(key)
    alt cache hit
        Cache-->>ContactService: JSON map
        ContactService-->>MessageThreadService: "map[phone]*Contact"
    else cache miss or invalidated
        Cache-->>ContactService: empty or error
        ContactService->>ContactRepository: FetchAll(userID)
        ContactRepository-->>ContactService: []Contact
        ContactService->>Cache: Set(key, encodedMap, 24h)
        ContactService-->>MessageThreadService: "map[phone]*Contact"
    end
    MessageThreadService-->>ContactHandler: []MessageThread with ContactDetails
    ContactHandler-->>Client: 200 OK
Loading

Reviews (1): Last reviewed commit: "fix(web): mark contacts headers unsortab..." | Re-trigger Greptile

Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error

// DeleteAllForUser deletes all contacts for a user.
DeleteAllForUser(ctx context.Context, userID entities.UserID) error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DeleteAllForUser never wired to the UserAccountDeleted event

Every other entity in the codebase — billing, Discord, heartbeat, message threads, phone notifications, webhooks, etc. — has a dedicated listener that calls DeleteAllForUser when a UserAccountDeleted event fires (see e.g. billing_listener.go, message_thread_listener.go). The ContactRepository defines this method and ContactService could expose it, but no ContactListener was registered. As a result, all contacts for a deleted user remain in the database indefinitely, constituting a data-retention/compliance gap.

Comment on lines +87 to +88
if len(query) > 0 {
queryPattern := "%" + query + "%"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 User-supplied query is used verbatim as a LIKE pattern, enabling wildcard leakage

queryPattern is built by concatenating % around the raw query string. If a user includes SQL LIKE wildcard characters (% or _) in their search term, they act as wildcards against the columns. For example, searching _ matches every entry (any single character). The query is parameterized so there is no SQL injection risk, but the semantics are incorrect — a user expecting an exact substring match gets a much broader result. Escaping the user input before inserting it into the pattern fixes this.

Suggested change
if len(query) > 0 {
queryPattern := "%" + query + "%"
if len(query) > 0 {
escaped := strings.NewReplacer(`%`, `\%`, `_`, `\_`).Replace(query)
queryPattern := "%" + escaped + "%"

Comment on lines +156 to +169
contacts, err := service.repository.FetchAll(ctx, userID)
if err != nil {
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts to build map for user [%s]", userID))
}

result := make(map[string]*entities.Contact, len(*contacts))
for index := range *contacts {
// Take a fresh copy of the slice element so the map holds a stable pointer
// that does not alias the underlying slice storage.
contact := (*contacts)[index]
for _, number := range contact.PhoneNumbers {
result[number] = &contact
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 FetchAll loads every contact for a user with no upper bound

GetContactMap calls FetchAll which issues a SELECT * with no LIMIT. On every cache miss or post-mutation rebuild, all of a user's contacts are pulled into memory at once. A power user with tens of thousands of contacts would trigger a very large in-memory allocation and a long-running query. Given the 24 h TTL and the fact that every mutation (create, update, delete) invalidates the cache, heavy writers could trigger this repeatedly within a day. Consider streaming or paginating the build, or at minimum adding an application-level cap with an error when the contact list exceeds a safe threshold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant