Skip to content

Commit eddf175

Browse files
committed
feat(contacts): add MongoDB repository
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47a18375-9f8b-4f08-9014-ef78dcb72780
1 parent 568e106 commit eddf175

7 files changed

Lines changed: 437 additions & 14 deletions

File tree

api/pkg/di/container.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -925,12 +925,22 @@ func (container *Container) MessageThreadRepository() (repository repositories.M
925925

926926
// ContactRepository creates a new instance of repositories.ContactRepository
927927
func (container *Container) ContactRepository() (repository repositories.ContactRepository) {
928-
container.logger.Debug("creating GORM repositories.ContactRepository")
929-
return repositories.NewGormContactRepository(
930-
container.Logger(),
931-
container.Tracer(),
932-
container.DB(),
933-
)
928+
switch os.Getenv("CONTACT_DB_BACKEND") {
929+
case "mongodb":
930+
container.logger.Debug("creating MongoDB repositories.ContactRepository")
931+
return repositories.NewMongoContactRepository(
932+
container.Logger(),
933+
container.Tracer(),
934+
container.MongoDB(),
935+
)
936+
default:
937+
container.logger.Debug("creating GORM repositories.ContactRepository")
938+
return repositories.NewGormContactRepository(
939+
container.Logger(),
940+
container.Tracer(),
941+
container.DB(),
942+
)
943+
}
934944
}
935945

936946
// HeartbeatMonitorRepository creates a new instance of repositories.HeartbeatMonitorRepository

api/pkg/entities/contact.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ func (p *ContactProperties) Scan(src any) error {
6363

6464
// Contact represents a saved contact belonging to a user.
6565
type Contact struct {
66-
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
67-
UserID UserID `json:"user_id" gorm:"index" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
68-
Name string `json:"name" example:"Alice Smith"`
69-
Emails pq.StringArray `json:"emails" gorm:"type:text[]" swaggertype:"array,string" example:"alice@example.com"`
70-
PhoneNumbers pq.StringArray `json:"phone_numbers" gorm:"type:text[]" swaggertype:"array,string" example:"+18005550199,+18005550100"`
71-
Properties ContactProperties `json:"properties" gorm:"type:jsonb" swaggertype:"object,string"`
72-
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
73-
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:02.302718+03:00"`
66+
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid" bson:"_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
67+
UserID UserID `json:"user_id" gorm:"index" bson:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
68+
Name string `json:"name" bson:"name" example:"Alice Smith"`
69+
Emails pq.StringArray `json:"emails" gorm:"type:text[]" bson:"emails" swaggertype:"array,string" example:"alice@example.com"`
70+
PhoneNumbers pq.StringArray `json:"phone_numbers" gorm:"type:text[]" bson:"phone_numbers" swaggertype:"array,string" example:"+18005550199,+18005550100"`
71+
Properties ContactProperties `json:"properties" gorm:"type:jsonb" bson:"properties" swaggertype:"object,string"`
72+
CreatedAt time.Time `json:"created_at" bson:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
73+
UpdatedAt time.Time `json:"updated_at" bson:"updated_at" example:"2022-06-05T14:26:02.302718+03:00"`
7474
}
7575

7676
// TableName overrides the table name used by Contact.

api/pkg/entities/contact_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
11
package entities
22

33
import (
4+
"reflect"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
78
)
89

10+
func TestContact_BSONFieldNames(t *testing.T) {
11+
contactType := reflect.TypeOf(Contact{})
12+
expected := map[string]string{
13+
"ID": "_id",
14+
"UserID": "user_id",
15+
"Name": "name",
16+
"Emails": "emails",
17+
"PhoneNumbers": "phone_numbers",
18+
"Properties": "properties",
19+
"CreatedAt": "created_at",
20+
"UpdatedAt": "updated_at",
21+
}
22+
23+
for fieldName, bsonName := range expected {
24+
field, found := contactType.FieldByName(fieldName)
25+
assert.True(t, found)
26+
assert.Equal(t, bsonName, field.Tag.Get("bson"))
27+
}
28+
}
29+
930
func TestContactProperties_ValueScanRoundTrip(t *testing.T) {
1031
cases := []ContactProperties{
1132
nil,
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package repositories
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
8+
"github.com/NdoleStudio/httpsms/pkg/entities"
9+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
10+
"github.com/NdoleStudio/stacktrace"
11+
"github.com/google/uuid"
12+
"go.mongodb.org/mongo-driver/v2/bson"
13+
"go.mongodb.org/mongo-driver/v2/mongo"
14+
"go.mongodb.org/mongo-driver/v2/mongo/options"
15+
)
16+
17+
// mongoContactRepository is responsible for persisting entities.Contact in MongoDB.
18+
type mongoContactRepository struct {
19+
logger telemetry.Logger
20+
tracer telemetry.Tracer
21+
collection *mongo.Collection
22+
}
23+
24+
// NewMongoContactRepository creates the MongoDB version of the ContactRepository.
25+
func NewMongoContactRepository(
26+
logger telemetry.Logger,
27+
tracer telemetry.Tracer,
28+
db *mongo.Database,
29+
) ContactRepository {
30+
return &mongoContactRepository{
31+
logger: logger.WithService(fmt.Sprintf("%T", &mongoContactRepository{})),
32+
tracer: tracer,
33+
collection: db.Collection(collectionContacts),
34+
}
35+
}
36+
37+
func (repository *mongoContactRepository) Store(ctx context.Context, contacts []*entities.Contact) error {
38+
ctx, span := repository.tracer.Start(ctx)
39+
defer span.End()
40+
41+
if len(contacts) == 0 {
42+
return nil
43+
}
44+
45+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
46+
defer cancel()
47+
48+
documents := make([]any, len(contacts))
49+
for index, contact := range contacts {
50+
documents[index] = contact
51+
}
52+
53+
session, err := repository.collection.Database().Client().StartSession()
54+
if err != nil {
55+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot start transaction to store [%d] contacts", len(contacts)))
56+
}
57+
defer session.EndSession(ctx)
58+
59+
_, err = session.WithTransaction(ctx, func(transactionCtx context.Context) (any, error) {
60+
return repository.collection.InsertMany(transactionCtx, documents)
61+
})
62+
if err != nil {
63+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store [%d] contacts", len(contacts)))
64+
}
65+
66+
return nil
67+
}
68+
69+
func (repository *mongoContactRepository) Update(ctx context.Context, contact *entities.Contact) error {
70+
ctx, span := repository.tracer.Start(ctx)
71+
defer span.End()
72+
73+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
74+
defer cancel()
75+
76+
filter := mongoContactIDFilter(contact.UserID, contact.ID)
77+
if _, err := repository.collection.ReplaceOne(ctx, filter, contact); err != nil {
78+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update contact with ID [%s]", contact.ID))
79+
}
80+
81+
return nil
82+
}
83+
84+
func (repository *mongoContactRepository) Load(ctx context.Context, userID entities.UserID, contactID uuid.UUID) (*entities.Contact, error) {
85+
ctx, span := repository.tracer.Start(ctx)
86+
defer span.End()
87+
88+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
89+
defer cancel()
90+
91+
contact := new(entities.Contact)
92+
err := repository.collection.FindOne(ctx, mongoContactIDFilter(userID, contactID)).Decode(contact)
93+
if err == mongo.ErrNoDocuments {
94+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "contact with ID [%s] for user [%s] does not exist", contactID, userID))
95+
}
96+
if err != nil {
97+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load contact with ID [%s] for user [%s]", contactID, userID))
98+
}
99+
100+
return contact, nil
101+
}
102+
103+
func (repository *mongoContactRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) {
104+
ctx, span := repository.tracer.Start(ctx)
105+
defer span.End()
106+
107+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
108+
defer cancel()
109+
110+
findOptions := options.Find().
111+
SetSort(mongoContactSort(params)).
112+
SetSkip(int64(params.Skip)).
113+
SetLimit(int64(params.Limit))
114+
cursor, err := repository.collection.Find(ctx, mongoContactFilter(userID, params.Query), findOptions)
115+
if err != nil {
116+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index contacts for user [%s] with params [%+#v]", userID, params))
117+
}
118+
defer cursor.Close(ctx)
119+
120+
contacts := make([]entities.Contact, 0)
121+
if err = cursor.All(ctx, &contacts); err != nil {
122+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode contacts for user [%s]", userID))
123+
}
124+
125+
return &contacts, nil
126+
}
127+
128+
func (repository *mongoContactRepository) Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error) {
129+
ctx, span := repository.tracer.Start(ctx)
130+
defer span.End()
131+
132+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
133+
defer cancel()
134+
135+
count, err := repository.collection.CountDocuments(ctx, mongoContactFilter(userID, params.Query))
136+
if err != nil {
137+
return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count contacts for user [%s] with query [%s]", userID, params.Query))
138+
}
139+
140+
return count, nil
141+
}
142+
143+
func (repository *mongoContactRepository) FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) {
144+
ctx, span := repository.tracer.Start(ctx)
145+
defer span.End()
146+
147+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
148+
defer cancel()
149+
150+
findOptions := options.Find().SetSort(bson.D{
151+
{Key: "updated_at", Value: 1},
152+
{Key: "_id", Value: 1},
153+
})
154+
cursor, err := repository.collection.Find(ctx, mongoContactPhoneNumbersFilter(userID, phoneNumbers), findOptions)
155+
if err != nil {
156+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts for user [%s] by phone numbers [%v]", userID, phoneNumbers))
157+
}
158+
defer cursor.Close(ctx)
159+
160+
contacts := make([]entities.Contact, 0)
161+
if err = cursor.All(ctx, &contacts); err != nil {
162+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode contacts for user [%s] by phone numbers", userID))
163+
}
164+
165+
return &contacts, nil
166+
}
167+
168+
func (repository *mongoContactRepository) Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error {
169+
ctx, span := repository.tracer.Start(ctx)
170+
defer span.End()
171+
172+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
173+
defer cancel()
174+
175+
if _, err := repository.collection.DeleteOne(ctx, mongoContactIDFilter(userID, contactID)); err != nil {
176+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete contact with ID [%s] for user [%s]", contactID, userID))
177+
}
178+
179+
return nil
180+
}
181+
182+
func (repository *mongoContactRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
183+
ctx, span := repository.tracer.Start(ctx)
184+
defer span.End()
185+
186+
ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
187+
defer cancel()
188+
189+
if _, err := repository.collection.DeleteMany(ctx, bson.D{{Key: "user_id", Value: string(userID)}}); err != nil {
190+
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all contacts for user [%s]", userID))
191+
}
192+
193+
return nil
194+
}
195+
196+
func mongoContactFilter(userID entities.UserID, query string) bson.D {
197+
filter := bson.D{{Key: "user_id", Value: string(userID)}}
198+
if query == "" {
199+
return filter
200+
}
201+
202+
expression := bson.Regex{Pattern: regexp.QuoteMeta(query), Options: "i"}
203+
return append(filter, bson.E{Key: "$or", Value: bson.A{
204+
bson.D{{Key: "name", Value: expression}},
205+
bson.D{{Key: "emails", Value: expression}},
206+
bson.D{{Key: "phone_numbers", Value: expression}},
207+
}})
208+
}
209+
210+
func mongoContactSort(params IndexParams) bson.D {
211+
sortBy := "updated_at"
212+
if params.SortBy == "name" {
213+
sortBy = "name"
214+
}
215+
216+
direction := 1
217+
if params.SortBy == "" || params.SortDescending {
218+
direction = -1
219+
}
220+
221+
return bson.D{
222+
{Key: sortBy, Value: direction},
223+
{Key: "_id", Value: direction},
224+
}
225+
}
226+
227+
func mongoContactPhoneNumbersFilter(userID entities.UserID, phoneNumbers []string) bson.D {
228+
return bson.D{
229+
{Key: "user_id", Value: string(userID)},
230+
{Key: "phone_numbers", Value: bson.D{{Key: "$in", Value: phoneNumbers}}},
231+
}
232+
}
233+
234+
func mongoContactIDFilter(userID entities.UserID, contactID uuid.UUID) bson.D {
235+
return bson.D{
236+
{Key: "user_id", Value: string(userID)},
237+
{Key: "_id", Value: contactID.String()},
238+
}
239+
}

0 commit comments

Comments
 (0)