• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 16522415145

25 Jul 2025 12:49PM UTC coverage: 57.876% (+0.06%) from 57.815%
16522415145

Pull #198

github

emmdim
db: Fixes bug in calculating duplicate members during data validation
Pull Request #198: all: removes censusType from CreateCensus requests. Updates setCensus…

34 of 41 new or added lines in 4 files covered. (82.93%)

4 existing lines in 1 file now uncovered.

5236 of 9047 relevant lines covered (57.88%)

26.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.1
/db/org_members_groups.go
1
package db
2

3
import (
4
        "context"
5
        "fmt"
6
        "math"
7
        "strings"
8
        "time"
9

10
        "github.com/ethereum/go-ethereum/common"
11
        "go.mongodb.org/mongo-driver/bson"
12
        "go.mongodb.org/mongo-driver/bson/primitive"
13
        "go.mongodb.org/mongo-driver/mongo"
14
        "go.mongodb.org/mongo-driver/mongo/options"
15
        "go.vocdoni.io/dvote/log"
16
)
17

18
// OrgMembersGroup returns an organization members group
19
func (ms *MongoStorage) OrganizationMemberGroup(
20
        groupID string,
21
        orgAddress common.Address,
22
) (*OrganizationMemberGroup, error) {
64✔
23
        if orgAddress.Cmp(common.Address{}) == 0 {
65✔
24
                return nil, ErrInvalidData
1✔
25
        }
1✔
26
        objID, err := primitive.ObjectIDFromHex(groupID)
63✔
27
        if err != nil {
68✔
28
                return nil, ErrInvalidData
5✔
29
        }
5✔
30

31
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
58✔
32
        // create a context with a timeout
58✔
33
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
58✔
34
        defer cancel()
58✔
35
        // find the organization in the database
58✔
36
        result := ms.orgMemberGroups.FindOne(ctx, filter)
58✔
37
        var group *OrganizationMemberGroup
58✔
38
        if err := result.Decode(&group); err != nil {
69✔
39
                // if the organization doesn't exist return a specific error
11✔
40
                if err == mongo.ErrNoDocuments {
22✔
41
                        return nil, ErrNotFound
11✔
42
                }
11✔
43
                return nil, err
×
44
        }
45
        return group, nil
47✔
46
}
47

48
// OrganizationMemberGroups returns the list of an organization's members groups
49
func (ms *MongoStorage) OrganizationMemberGroups(
50
        orgAddress common.Address,
51
        page, pageSize int,
52
) (int, []*OrganizationMemberGroup, error) {
8✔
53
        if orgAddress.Cmp(common.Address{}) == 0 {
9✔
54
                return 0, nil, ErrInvalidData
1✔
55
        }
1✔
56
        // create a context with a timeout
57
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
7✔
58
        defer cancel()
7✔
59

7✔
60
        filter := bson.M{"orgAddress": orgAddress}
7✔
61

7✔
62
        // Count total documents
7✔
63
        totalCount, err := ms.orgMemberGroups.CountDocuments(ctx, filter)
7✔
64
        if err != nil {
7✔
65
                return 0, nil, err
×
66
        }
×
67
        totalPages := int(math.Ceil(float64(totalCount) / float64(pageSize)))
7✔
68

7✔
69
        // Calculate skip value based on page and pageSize
7✔
70
        skip := (page - 1) * pageSize
7✔
71

7✔
72
        // Set up options for pagination
7✔
73
        findOptions := options.Find().
7✔
74
                SetSkip(int64(skip)).
7✔
75
                SetLimit(int64(pageSize))
7✔
76

7✔
77
        // find the organization in the database
7✔
78
        cursor, err := ms.orgMemberGroups.Find(ctx, filter, findOptions)
7✔
79
        if err != nil {
7✔
80
                return 0, nil, err
×
81
        }
×
82
        defer func() {
14✔
83
                if err := cursor.Close(ctx); err != nil {
7✔
84
                        log.Warnw("error closing cursor", "error", err)
×
85
                }
×
86
        }()
87

88
        var groups []*OrganizationMemberGroup
7✔
89
        for cursor.Next(ctx) {
21✔
90
                var group OrganizationMemberGroup
14✔
91
                if err := cursor.Decode(&group); err != nil {
14✔
92
                        return 0, nil, err
×
93
                }
×
94
                groups = append(groups, &group)
14✔
95
        }
96

97
        if err := cursor.Err(); err != nil {
7✔
98
                return 0, nil, err
×
99
        }
×
100

101
        return totalPages, groups, nil
7✔
102
}
103

104
// CreateOrganizationMemberGroup Creates an organization member group
105
func (ms *MongoStorage) CreateOrganizationMemberGroup(group *OrganizationMemberGroup) (string, error) {
39✔
106
        // create a context with a timeout
39✔
107
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
39✔
108
        defer cancel()
39✔
109

39✔
110
        if group == nil || group.OrgAddress.Cmp(common.Address{}) == 0 || len(group.MemberIDs) == 0 {
40✔
111
                return "", ErrInvalidData
1✔
112
        }
1✔
113

114
        // check that the organization exists
115
        if _, err := ms.fetchOrganizationFromDB(ctx, group.OrgAddress); err != nil {
39✔
116
                if err == ErrNotFound {
2✔
117
                        return "", ErrInvalidData
1✔
118
                }
1✔
119
                return "", fmt.Errorf("organization not found: %w", err)
×
120
        }
121
        // check that the members are valid
122
        err := ms.validateOrgMembers(ctx, group.OrgAddress, group.MemberIDs)
37✔
123
        if err != nil {
39✔
124
                return "", err
2✔
125
        }
2✔
126
        // create the group id
127
        group.ID = primitive.NewObjectID()
35✔
128
        group.CreatedAt = time.Now()
35✔
129
        group.UpdatedAt = time.Now()
35✔
130
        group.CensusIDs = make([]string, 0)
35✔
131

35✔
132
        // Only lock the mutex during the actual database operations
35✔
133
        ms.keysLock.Lock()
35✔
134
        defer ms.keysLock.Unlock()
35✔
135

35✔
136
        // insert the group into the database
35✔
137
        if _, err := ms.orgMemberGroups.InsertOne(ctx, *group); err != nil {
35✔
138
                return "", fmt.Errorf("could not create organization members group: %w", err)
×
139
        }
×
140
        return group.ID.Hex(), nil
35✔
141
}
142

143
// UpdateOrganizationMemberGroup updates an organization members group by adding
144
// and/or removing members. If a member exists in both lists, it will be removed
145
// TODO allow to update the rest of the fields as well. Maybe a different function?
146
func (ms *MongoStorage) UpdateOrganizationMemberGroup(
147
        groupID string, orgAddress common.Address,
148
        title, description string, addedMembers, removedMembers []string,
149
) error {
10✔
150
        if orgAddress.Cmp(common.Address{}) == 0 {
11✔
151
                return ErrInvalidData
1✔
152
        }
1✔
153
        group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
9✔
154
        if err != nil {
11✔
155
                if err == ErrNotFound {
3✔
156
                        return ErrInvalidData
1✔
157
                }
1✔
158
                return fmt.Errorf("could not retrieve organization members group: %w", err)
1✔
159
        }
160
        // create a context with a timeout
161
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
7✔
162
        defer cancel()
7✔
163
        objID, err := primitive.ObjectIDFromHex(groupID)
7✔
164
        if err != nil {
7✔
165
                return err
×
166
        }
×
167

168
        // check that the addedMembers contains valid IDs from the orgMembers collection
169
        if len(addedMembers) > 0 {
10✔
170
                err = ms.validateOrgMembers(ctx, group.OrgAddress, addedMembers)
3✔
171
                if err != nil {
4✔
172
                        return err
1✔
173
                }
1✔
174
        }
175

176
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
6✔
177

6✔
178
        // Only lock the mutex during the actual database operations
6✔
179
        ms.keysLock.Lock()
6✔
180
        defer ms.keysLock.Unlock()
6✔
181

6✔
182
        // First, update metadata if needed
6✔
183
        if title != "" || description != "" {
10✔
184
                updateFields := bson.M{}
4✔
185
                if title != "" {
8✔
186
                        updateFields["title"] = title
4✔
187
                }
4✔
188
                if description != "" {
8✔
189
                        updateFields["description"] = description
4✔
190
                }
4✔
191
                updateFields["updatedAt"] = time.Now()
4✔
192

4✔
193
                metadataUpdate := bson.D{{Key: "$set", Value: updateFields}}
4✔
194
                _, err = ms.orgMemberGroups.UpdateOne(ctx, filter, metadataUpdate)
4✔
195
                if err != nil {
4✔
196
                        return err
×
197
                }
×
198
        }
199

200
        // Get the updated group to ensure we have the latest state
201
        updatedGroup, err := ms.OrganizationMemberGroup(groupID, orgAddress)
6✔
202
        if err != nil {
6✔
203
                return err
×
204
        }
×
205

206
        // Now handle member updates if needed
207
        if len(addedMembers) > 0 || len(removedMembers) > 0 {
10✔
208
                // Calculate the final list of members
4✔
209
                finalMembers := make([]string, 0, len(updatedGroup.MemberIDs)+len(addedMembers))
4✔
210

4✔
211
                // Add existing members that aren't in the removedMembers list
4✔
212
                for _, id := range updatedGroup.MemberIDs {
13✔
213
                        if !contains(removedMembers, id) {
15✔
214
                                finalMembers = append(finalMembers, id)
6✔
215
                        }
6✔
216
                }
217

218
                // Add new members that aren't already in the list
219
                for _, id := range addedMembers {
7✔
220
                        if !contains(finalMembers, id) {
6✔
221
                                finalMembers = append(finalMembers, id)
3✔
222
                        }
3✔
223
                }
224

225
                // Update the member list
226
                memberUpdate := bson.D{{Key: "$set", Value: bson.M{
4✔
227
                        "memberIds": finalMembers,
4✔
228
                        "updatedAt": time.Now(),
4✔
229
                }}}
4✔
230

4✔
231
                _, err = ms.orgMemberGroups.UpdateOne(ctx, filter, memberUpdate)
4✔
232
                if err != nil {
4✔
233
                        return err
×
234
                }
×
235
        }
236

237
        return nil
6✔
238
}
239

240
// AddOrganizationMemberGroupCensus adds a census to an organization member group
241
func (ms *MongoStorage) addOrganizationMemberGroupCensus(
242
        ctx context.Context, groupID string, orgAddress common.Address, censusID string,
243
) error {
6✔
244
        if orgAddress.Cmp(common.Address{}) == 0 {
6✔
NEW
245
                return ErrInvalidData
×
NEW
246
        }
×
247

248
        objID, err := primitive.ObjectIDFromHex(groupID)
6✔
249
        if err != nil {
6✔
NEW
250
                return fmt.Errorf("invalid group ID: %w", err)
×
NEW
251
        }
×
252

253
        // update the group with the census ID
254
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
6✔
255
        update := bson.D{{Key: "$addToSet", Value: bson.M{"censusIds": censusID}}}
6✔
256
        _, err = ms.orgMemberGroups.UpdateOne(ctx, filter, update)
6✔
257
        return err
6✔
258
}
259

260
// DeleteOrganizationMemberGroup deletes an organization member group by its ID
261
func (ms *MongoStorage) DeleteOrganizationMemberGroup(groupID string, orgAddress common.Address) error {
7✔
262
        if orgAddress.Cmp(common.Address{}) == 0 {
8✔
263
                return ErrInvalidData
1✔
264
        }
1✔
265
        // create a context with a timeout
266
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
6✔
267
        defer cancel()
6✔
268

6✔
269
        objID, err := primitive.ObjectIDFromHex(groupID)
6✔
270
        if err != nil {
7✔
271
                return fmt.Errorf("invalid group ID: %w", err)
1✔
272
        }
1✔
273

274
        // Only lock the mutex during the actual database operations
275
        ms.keysLock.Lock()
5✔
276
        defer ms.keysLock.Unlock()
5✔
277

5✔
278
        // delete the group from the database
5✔
279
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
5✔
280
        if _, err := ms.orgMemberGroups.DeleteOne(ctx, filter); err != nil {
5✔
281
                return fmt.Errorf("could not delete organization members group: %w", err)
×
282
        }
×
283
        return nil
5✔
284
}
285

286
// ListOrganizationMemberGroup lists all the members of an organization member group and the total number of members
287
func (ms *MongoStorage) ListOrganizationMemberGroup(
288
        groupID string, orgAddress common.Address,
289
        page, pageSize int64,
290
) (int, []*OrgMember, error) {
10✔
291
        if orgAddress.Cmp(common.Address{}) == 0 {
11✔
292
                return 0, nil, ErrInvalidData
1✔
293
        }
1✔
294
        // get the group
295
        group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
9✔
296
        if err != nil {
12✔
297
                return 0, nil, fmt.Errorf("could not retrieve organization members group: %w", err)
3✔
298
        }
3✔
299

300
        return ms.orgMembersByIDs(
6✔
301
                orgAddress,
6✔
302
                group.MemberIDs,
6✔
303
                page,
6✔
304
                pageSize,
6✔
305
        )
6✔
306
}
307

308
// CheckOrgMemberAuthFields checks if the provided orgFields are valid for authentication
309
// Checks the entire member base of an organization creating a projection that contains only
310
// the provided auth fields and verifies that the resulting data do not have duplicates or
311
// missing fields. Returns the corrsponding informative errors concerning duplicates or columns with empty values
312
// The authFields are checked for missing data and duplicates while the twoFaFields are only checked for missing data
313
func (ms *MongoStorage) CheckGroupMembersFields(
314
        orgAddress common.Address,
315
        groupID string,
316
        authFields OrgMemberAuthFields,
317
        twoFaFields OrgMemberTwoFaFields,
318
) (*OrgMemberAggregationResults, error) {
19✔
319
        if orgAddress.Cmp(common.Address{}) == 0 {
20✔
320
                return nil, ErrInvalidData
1✔
321
        }
1✔
322
        if len(authFields) == 0 && len(twoFaFields) == 0 {
19✔
323
                return nil, fmt.Errorf("no auth or twoFa fields provided")
1✔
324
        }
1✔
325

326
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
17✔
327
        defer cancel()
17✔
328

17✔
329
        // 2) Fetch all matching docs
17✔
330
        cur, err := ms.getGroupMembersFields(ctx, orgAddress, groupID, authFields, twoFaFields)
17✔
331
        if err != nil {
20✔
332
                return nil, err
3✔
333
        }
3✔
334
        defer func() {
28✔
335
                if err := cur.Close(ctx); err != nil {
14✔
336
                        log.Warnw("error closing cursor", "error", err)
×
337
                }
×
338
        }()
339

340
        results := OrgMemberAggregationResults{
14✔
341
                Members:     make([]primitive.ObjectID, 0),
14✔
342
                Duplicates:  make([]primitive.ObjectID, 0),
14✔
343
                MissingData: make([]primitive.ObjectID, 0),
14✔
344
        }
14✔
345

14✔
346
        seenKeys := make(map[string]primitive.ObjectID, cur.RemainingBatchLength())
14✔
347
        duplicates := make(map[primitive.ObjectID]struct{}, 0)
14✔
348

14✔
349
        // 4) Iterate and detect
14✔
350
        for cur.Next(ctx) {
59✔
351
                // decode into a map so we can handle dynamic fields
45✔
352
                var m OrgMember
45✔
353
                var bm bson.M
45✔
354
                if err := cur.Decode(&m); err != nil {
45✔
355
                        return nil, err
×
356
                }
×
357
                if err := cur.Decode(&bm); err != nil {
45✔
358
                        return nil, err
×
359
                }
×
360

361
                // if any of the fields are empty, add to missing data
362
                // and continue to the next member
363
                // we do not check for duplicates in empty rows
364
                if hasEmptyFields(bm, authFields) || hasEmptyFields(bm, twoFaFields) {
51✔
365
                        results.MissingData = append(results.MissingData, m.ID)
6✔
366
                        continue
6✔
367
                }
368

369
                // if the key is already seen, add to duplicates
370
                // and continue to the next member
371
                if len(authFields) > 0 {
68✔
372
                        key := buildKey(bm, authFields)
29✔
373
                        if val, seen := seenKeys[key]; seen {
35✔
374
                                duplicates[m.ID] = struct{}{}
6✔
375
                                duplicates[val] = struct{}{}
6✔
376
                                continue
6✔
377
                        }
378
                        // neither empty nor duplicate, so we add it to the seen keys
379
                        seenKeys[key] = m.ID
23✔
380
                }
381

382
                // if thedata pass all checkss  append the member ID to the results
383
                results.Members = append(results.Members, m.ID)
33✔
384

385
        }
386
        if err := cur.Err(); err != nil {
14✔
387
                return nil, err
×
388
        }
×
389
        results.Duplicates = mapKeysToSlice(duplicates)
14✔
390

14✔
391
        return &results, nil
14✔
392
}
393

394
// getGroupMembersAuthFields creates a projection of a set of members that
395
// contains only the chosen AuthFields
396
func (ms *MongoStorage) getGroupMembersFields(
397
        ctx context.Context,
398
        orgAddress common.Address,
399
        groupID string,
400
        authFields OrgMemberAuthFields,
401
        twoFaFields OrgMemberTwoFaFields,
402
) (*mongo.Cursor, error) {
17✔
403
        // 1) Build find filter and projection
17✔
404
        filter := bson.D{
17✔
405
                {Key: "orgAddress", Value: orgAddress},
17✔
406
        }
17✔
407
        // in case a groupID is provided, fetch the group and its members and
17✔
408
        // extend the filter to include only those members
17✔
409
        if len(groupID) > 0 {
31✔
410
                group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
14✔
411
                if err != nil {
17✔
412
                        if err == ErrNotFound {
5✔
413
                                return nil, fmt.Errorf("group %s not found for organization %s: %w", groupID, orgAddress, ErrInvalidData)
2✔
414
                        }
2✔
415
                        return nil, fmt.Errorf("failed to fetch group %s for organization %s: %w", groupID, orgAddress, err)
1✔
416
                }
417
                // Check if the group has members
418
                if len(group.MemberIDs) == 0 {
11✔
419
                        return nil, fmt.Errorf("no members in group %s for organization %s", groupID, orgAddress)
×
420
                }
×
421
                objectIDs := make([]primitive.ObjectID, len(group.MemberIDs))
11✔
422
                for i, id := range group.MemberIDs {
54✔
423
                        objID, err := primitive.ObjectIDFromHex(id)
43✔
424
                        if err != nil {
43✔
425
                                return nil, fmt.Errorf("invalid member ID %s: %w", id, ErrInvalidData)
×
426
                        }
×
427
                        objectIDs[i] = objID
43✔
428
                }
429
                if len(objectIDs) > 0 {
22✔
430
                        filter = append(filter, bson.E{Key: "_id", Value: bson.M{"$in": objectIDs}})
11✔
431
                }
11✔
432
        }
433

434
        proj := bson.D{
14✔
435
                {Key: "_id", Value: 1},
14✔
436
                {Key: "orgAddress", Value: 1},
14✔
437
        }
14✔
438
        // Add the authFields and twoFaFields to the projection
14✔
439
        for _, f := range authFields {
28✔
440
                proj = append(proj, bson.E{Key: string(f), Value: 1})
14✔
441
        }
14✔
442
        for _, f := range twoFaFields {
23✔
443
                proj = append(proj, bson.E{Key: string(f), Value: 1})
9✔
444
        }
9✔
445
        findOpts := options.Find().SetProjection(proj)
14✔
446

14✔
447
        // 2) Fetch all matching docs
14✔
448
        return ms.orgMembers.Find(ctx, filter, findOpts)
14✔
449
}
450

451
// Helper function to check if a string is in a slice
452
func contains(slice []string, item string) bool {
16✔
453
        for _, s := range slice {
35✔
454
                if s == item {
26✔
455
                        return true
7✔
456
                }
7✔
457
        }
458
        return false
9✔
459
}
460

461
// mapKeysToSlice extracts all keys from a map as a slice.
462
func mapKeysToSlice[T comparable, V any](m map[T]V) []T {
14✔
463
        keys := make([]T, 0, len(m))
14✔
464
        for k := range m {
25✔
465
                keys = append(keys, k)
11✔
466
        }
11✔
467
        return keys
14✔
468
}
469

470
// hasEmptyFields returns true if any of the specified fields in the BSON document are empty or nil.
471
func hasEmptyFields[T ~string](bm bson.M, fields []T) bool {
87✔
472
        for _, f := range fields {
154✔
473
                val := fmt.Sprint(bm[string(f)])
67✔
474
                if val == "" || bm[string(f)] == nil {
73✔
475
                        return true
6✔
476
                }
6✔
477
        }
478
        return false
81✔
479
}
480

481
// buildKey constructs a composite key from the values of specified fields in the BSON document.
482
// The values are concatenated with "|" as a delimiter.
483
func buildKey[T ~string](bm bson.M, fields []T) string {
29✔
484
        keyParts := make([]string, len(fields))
29✔
485
        for i, f := range fields {
73✔
486
                keyParts[i] = fmt.Sprint(bm[string(f)])
44✔
487
        }
44✔
488
        return strings.Join(keyParts, "|")
29✔
489
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc