Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
eb90685
docs: add contacts feature implementation plan
AchoArnold Jul 19, 2026
ba04503
feat(api): add Contact entity and ContactProperties jsonb type
AchoArnold Jul 19, 2026
d0e5b40
fix(api): wrap contact property errors
AchoArnold Jul 19, 2026
c8e86d0
feat(api): add ContactDetails to message threads
AchoArnold Jul 19, 2026
1296729
feat(api): add ContactRepository with GORM implementation
AchoArnold Jul 19, 2026
bf1a9fb
feat(api): add contact store/update/index requests
AchoArnold Jul 19, 2026
ad95d55
fix(api): keep contact sanitization ordered
AchoArnold Jul 19, 2026
1b75db2
feat(api): add contact validator
AchoArnold Jul 19, 2026
ceb85aa
fix(api): accept common CSV MIME types
AchoArnold Jul 19, 2026
0dd49ad
feat(api): add ContactService with cached contact-map resolution
AchoArnold Jul 19, 2026
5ec3d8d
feat(api): opt in contact resolution
AchoArnold Jul 19, 2026
77c20b8
feat(api): add ContactHandler and wire contacts into DI container
AchoArnold Jul 19, 2026
d835053
chore(api): regenerate contacts swagger
AchoArnold Jul 19, 2026
5a2bd38
fix(api): mark contact fields optional
AchoArnold Jul 19, 2026
2c4e7d4
chore(web): regenerate API models with Contact types
AchoArnold Jul 19, 2026
7dcde65
feat(web): add contacts Pinia store
AchoArnold Jul 19, 2026
9f9592f
fix(web): avoid false contact reload errors
AchoArnold Jul 19, 2026
d9f7807
feat(web): add contacts management page with add/edit/delete/import d…
AchoArnold Jul 19, 2026
f18c57d
fix(web): guard stale contacts loads
AchoArnold Jul 19, 2026
6c8561a
feat(web): display contact names on threads
AchoArnold Jul 19, 2026
eff5632
feat(api): return accurate contacts total for server pagination
AchoArnold Jul 19, 2026
b2dbc93
feat(web): server-side pagination for the contacts page
AchoArnold Jul 19, 2026
e942b98
fix(web): mark contacts headers unsortable
AchoArnold Jul 19, 2026
eb42cff
test: add contacts integration coverage
AchoArnold Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
539 changes: 539 additions & 0 deletions api/docs/docs.go

Large diffs are not rendered by default.

539 changes: 539 additions & 0 deletions api/docs/swagger.json

Large diffs are not rendered by default.

364 changes: 364 additions & 0 deletions api/docs/swagger.yaml

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterMessageThreadRoutes()
container.RegisterMessageThreadListeners()

container.RegisterContactRoutes()

container.RegisterHeartbeatRoutes()
container.RegisterHeartbeatListeners()

Expand Down Expand Up @@ -414,6 +416,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.PhoneAPIKey{}))
}

if err = db.AutoMigrate(&entities.Contact{}); err != nil {
container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Contact{}))
}

return container.db
}

Expand Down Expand Up @@ -697,6 +703,26 @@ func (container *Container) MessageThreadHandlerValidator() (validator *validato
)
}

// ContactHandlerValidator creates a new instance of validators.ContactHandlerValidator
func (container *Container) ContactHandlerValidator() (validator *validators.ContactHandlerValidator) {
container.logger.Debug(fmt.Sprintf("creating %T", validator))
return validators.NewContactHandlerValidator(
container.Logger(),
container.Tracer(),
)
}

// ContactHandler creates a new instance of handlers.ContactHandler
func (container *Container) ContactHandler() (h *handlers.ContactHandler) {
container.logger.Debug(fmt.Sprintf("creating %T", h))
return handlers.NewContactHandler(
container.Logger(),
container.Tracer(),
container.ContactHandlerValidator(),
container.ContactService(),
)
}

// PhoneHandlerValidator creates a new instance of validators.PhoneHandlerValidator
func (container *Container) PhoneHandlerValidator() (validator *validators.PhoneHandlerValidator) {
container.logger.Debug(fmt.Sprintf("creating %T", validator))
Expand Down Expand Up @@ -895,6 +921,16 @@ func (container *Container) MessageThreadRepository() (repository repositories.M
)
}

// ContactRepository creates a new instance of repositories.ContactRepository
func (container *Container) ContactRepository() (repository repositories.ContactRepository) {
container.logger.Debug("creating GORM repositories.ContactRepository")
return repositories.NewGormContactRepository(
container.Logger(),
container.Tracer(),
container.DB(),
)
}

// HeartbeatMonitorRepository creates a new instance of repositories.HeartbeatMonitorRepository
func (container *Container) HeartbeatMonitorRepository() (repository repositories.HeartbeatMonitorRepository) {
switch os.Getenv("HEARTBEAT_DB_BACKEND") {
Expand Down Expand Up @@ -1110,6 +1146,18 @@ func (container *Container) MessageThreadService() (service *services.MessageThr
container.MessageThreadRepository(),
container.PhoneRepository(),
container.EventDispatcher(),
container.ContactService(),
)
}

// ContactService creates a new instance of services.ContactService
func (container *Container) ContactService() (service *services.ContactService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
return services.NewContactService(
container.Logger(),
container.Tracer(),
container.ContactRepository(),
container.Cache(),
)
}

Expand Down Expand Up @@ -1664,6 +1712,12 @@ func (container *Container) RegisterMessageThreadRoutes() {
container.MessageThreadHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}

// RegisterContactRoutes registers routes for the /contacts prefix
func (container *Container) RegisterContactRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.ContactHandler{}))
container.ContactHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}

// RegisterHeartbeatRoutes registers routes for the /heartbeats prefix
func (container *Container) RegisterHeartbeatRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.HeartbeatHandler{}))
Expand Down
76 changes: 76 additions & 0 deletions api/pkg/entities/contact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package entities

import (
"database/sql/driver"
"encoding/json"
"time"

"github.com/NdoleStudio/stacktrace"
"github.com/google/uuid"
"github.com/lib/pq"
)

// ContactProperties is a free-form key/value map persisted as a jsonb column.
type ContactProperties map[string]string

// Value implements driver.Valuer, serializing the map to JSON bytes.
func (p ContactProperties) Value() (driver.Value, error) {
if p == nil {
return []byte("{}"), nil
}

data, err := json.Marshal(p)
if err != nil {
return nil, stacktrace.Propagatef(err, "cannot marshal ContactProperties")
}

return data, nil
}

// Scan implements sql.Scanner, deserializing jsonb bytes/string into the map.
func (p *ContactProperties) Scan(src any) error {
if src == nil {
*p = ContactProperties{}
return nil
}

var data []byte
switch value := src.(type) {
case []byte:
data = value
case string:
data = []byte(value)
default:
return stacktrace.NewErrorf("unsupported type [%T] for ContactProperties", src)
}

if len(data) == 0 {
*p = ContactProperties{}
return nil
}

result := ContactProperties{}
if err := json.Unmarshal(data, &result); err != nil {
return stacktrace.Propagatef(err, "cannot unmarshal ContactProperties")
}

*p = result
return nil
}

// Contact represents a saved contact belonging to a user.
type Contact struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
UserID UserID `json:"user_id" gorm:"index" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Name string `json:"name" example:"Alice Smith"`
Emails pq.StringArray `json:"emails" gorm:"type:text[]" swaggertype:"array,string" example:"alice@example.com"`
PhoneNumbers pq.StringArray `json:"phone_numbers" gorm:"type:text[]" swaggertype:"array,string" example:"+18005550199,+18005550100"`
Properties ContactProperties `json:"properties" gorm:"type:jsonb" swaggertype:"object,string"`
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:02.302718+03:00"`
}

// TableName overrides the table name used by Contact.
func (Contact) TableName() string {
return "contacts"
}
50 changes: 50 additions & 0 deletions api/pkg/entities/contact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package entities

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestContactProperties_ValueScanRoundTrip(t *testing.T) {
cases := []ContactProperties{
nil,
{},
{"company": "Acme", "role": "CTO"},
}

for _, original := range cases {
value, err := original.Value()
assert.Nil(t, err)

var scanned ContactProperties
assert.Nil(t, scanned.Scan(value))

if len(original) == 0 {
assert.Equal(t, 0, len(scanned))
continue
}
assert.Equal(t, original, scanned)
}
}

func TestContactProperties_ScanFromString(t *testing.T) {
var scanned ContactProperties
assert.Nil(t, scanned.Scan(`{"k":"v"}`))
assert.Equal(t, ContactProperties{"k": "v"}, scanned)
}

func TestContactProperties_ScanNil(t *testing.T) {
var scanned ContactProperties
assert.Nil(t, scanned.Scan(nil))
assert.Equal(t, 0, len(scanned))
}

func TestContactProperties_ScanUnsupportedType(t *testing.T) {
var scanned ContactProperties

err := scanned.Scan(123)

assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported type [int] for ContactProperties")
}
2 changes: 2 additions & 0 deletions api/pkg/entities/message_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type MessageThread struct {
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:09.527976+03:00"`
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:09.527976+03:00"`
OrderTimestamp time.Time `json:"order_timestamp" example:"2022-06-05T14:26:09.527976+03:00"`
// ContactDetails is resolved at read time and never persisted.
ContactDetails *Contact `json:"contact_details,omitempty" gorm:"-"`
}

// Update a message thread after a message event
Expand Down
18 changes: 18 additions & 0 deletions api/pkg/entities/message_thread_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package entities

import (
"encoding/json"
"reflect"
"testing"

Expand All @@ -23,3 +24,20 @@ func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) {
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP")
assert.Equal(t, "-", lastReadAt.Tag.Get("json"))
}

func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) {
threadType := reflect.TypeOf(MessageThread{})

contactDetails, ok := threadType.FieldByName("ContactDetails")
require.True(t, ok)
assert.Equal(t, "*entities.Contact", contactDetails.Type.String())
assert.Equal(t, "contact_details,omitempty", contactDetails.Tag.Get("json"))
assert.Equal(t, "-", contactDetails.Tag.Get("gorm"))

data, err := json.Marshal(MessageThread{})
require.NoError(t, err)

var payload map[string]any
require.NoError(t, json.Unmarshal(data, &payload))
assert.NotContains(t, payload, "contact_details")
}
Loading
Loading