Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
64cc1b7
fix: fix lockfile
AchoArnold Aug 21, 2026
fbe0230
docs: add contacts feature implementation plan
AchoArnold Jul 19, 2026
1a6983e
feat(api): add Contact entity and ContactProperties jsonb type
AchoArnold Jul 19, 2026
af576e6
fix(api): wrap contact property errors
AchoArnold Jul 19, 2026
2d1290f
feat(api): add ContactDetails to message threads
AchoArnold Jul 19, 2026
d30bb16
feat(api): add ContactRepository with GORM implementation
AchoArnold Jul 19, 2026
2030b30
feat(api): add contact store/update/index requests
AchoArnold Jul 19, 2026
a3c4eed
fix(api): keep contact sanitization ordered
AchoArnold Jul 19, 2026
30c55a1
feat(api): add contact validator
AchoArnold Jul 19, 2026
c107897
fix(api): accept common CSV MIME types
AchoArnold Jul 19, 2026
536933e
feat(api): add ContactService with cached contact-map resolution
AchoArnold Jul 19, 2026
6a18474
feat(api): opt in contact resolution
AchoArnold Jul 19, 2026
481f6af
feat(api): add ContactHandler and wire contacts into DI container
AchoArnold Jul 19, 2026
c5e5383
chore(api): regenerate contacts swagger
AchoArnold Jul 19, 2026
a9ceb1a
fix(api): mark contact fields optional
AchoArnold Jul 19, 2026
754aaee
chore(web): regenerate API models with Contact types
AchoArnold Jul 19, 2026
e3ac1cd
feat(web): add contacts Pinia store
AchoArnold Jul 19, 2026
4a7089a
fix(web): avoid false contact reload errors
AchoArnold Jul 19, 2026
08f8d93
feat(web): add contacts management page with add/edit/delete/import d…
AchoArnold Jul 19, 2026
4aff603
fix(web): guard stale contacts loads
AchoArnold Jul 19, 2026
743da66
feat(web): display contact names on threads
AchoArnold Jul 19, 2026
e16b2c6
feat(api): return accurate contacts total for server pagination
AchoArnold Jul 19, 2026
8ef0d7f
feat(web): server-side pagination for the contacts page
AchoArnold Jul 19, 2026
9752e11
fix(web): mark contacts headers unsortable
AchoArnold Jul 19, 2026
43db65f
test: add contacts integration coverage
AchoArnold Jul 21, 2026
902ce34
fix(web): prefix Vuetify composables
AchoArnold Aug 21, 2026
48597e9
docs(web): define contacts header styling
AchoArnold Aug 21, 2026
afb5823
feat(web): refine contacts table
AchoArnold Aug 21, 2026
9284da6
docs(web): define contact phone input
AchoArnold Aug 21, 2026
13156ab
feat(web): improve contact dialog fields
AchoArnold Aug 21, 2026
5d2bb5a
fix: display of buttons on contacts page
AchoArnold Aug 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