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

@codacy-production

codacy-productionBot commented Jul 19, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues50 minor

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

Results:
50 new issues

CategoryResults
CodeStyle50 minor

View in Codacy

🟢 Metrics535 complexity · 203 duplication

MetricResults
Complexity535
Duplication203

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

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

FilenameOverview
api/pkg/repositories/contact_repository.goContactRepository 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.goGORM 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.goContactService 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.goHTTP 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.goValidates 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.goNew 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.goAdds 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.goContactStoreRequest with custom UnmarshalJSON supporting both JSON array and wrapped-object formats; SanitizeContactItem deduplicates and normalizes phone/email values.
web/app/stores/contacts.tsPinia contacts store with generation-based stale-response cancellation, server-side pagination state tracking, and local optimistic delete.
web/app/pages/contacts/index.vueFull 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.goWires 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.

P1DeleteAllForUser 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.

P2User-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
iflen(query) >0 {
queryPattern:="%"+query+"%"
iflen(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.

P2FetchAll 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.

@AchoArnold
AchoArnoldforce-pushed the feature/contacts branch 2 times, most recently from eb42cff to ef57f74CompareJuly 23, 2026 05:11
AchoArnoldand others added 24 commits July 26, 2026 13:06
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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f3addc9-71dd-4cb7-bfae-e6fdcc8511af
Sign up for freeto 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

@AchoArnold