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
a17ae99
docs: add contacts feature implementation plan
AchoArnold Jul 19, 2026
1ed404a
feat(api): add Contact entity and ContactProperties jsonb type
AchoArnold Jul 19, 2026
4594076
fix(api): wrap contact property errors
AchoArnold Jul 19, 2026
e23156a
feat(api): add ContactDetails to message threads
AchoArnold Jul 19, 2026
7632615
feat(api): add ContactRepository with GORM implementation
AchoArnold Jul 19, 2026
9f72974
feat(api): add contact store/update/index requests
AchoArnold Jul 19, 2026
2d4536f
fix(api): keep contact sanitization ordered
AchoArnold Jul 19, 2026
86f52c3
feat(api): add contact validator
AchoArnold Jul 19, 2026
363b00e
fix(api): accept common CSV MIME types
AchoArnold Jul 19, 2026
6b6a22d
feat(api): add ContactService with cached contact-map resolution
AchoArnold Jul 19, 2026
32ace71
feat(api): opt in contact resolution
AchoArnold Jul 19, 2026
75bdbcb
feat(api): add ContactHandler and wire contacts into DI container
AchoArnold Jul 19, 2026
10ae4b6
chore(api): regenerate contacts swagger
AchoArnold Jul 19, 2026
ff0a421
fix(api): mark contact fields optional
AchoArnold Jul 19, 2026
32160d1
chore(web): regenerate API models with Contact types
AchoArnold Jul 19, 2026
c4995f4
feat(web): add contacts Pinia store
AchoArnold Jul 19, 2026
e8d48f3
fix(web): avoid false contact reload errors
AchoArnold Jul 19, 2026
55f522f
feat(web): add contacts management page with add/edit/delete/import d…
AchoArnold Jul 19, 2026
b845b58
fix(web): guard stale contacts loads
AchoArnold Jul 19, 2026
efc7d21
feat(web): display contact names on threads
AchoArnold Jul 19, 2026
8ac8c20
feat(api): return accurate contacts total for server pagination
AchoArnold Jul 19, 2026
fad0dc0
feat(web): server-side pagination for the contacts page
AchoArnold Jul 19, 2026
e13f3a6
fix(web): mark contacts headers unsortable
AchoArnold Jul 19, 2026
64094cf
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