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

vocdoni / saas-backend / 16367652533

18 Jul 2025 09:53AM UTC coverage: 57.638% (+0.7%) from 56.89%
16367652533

Pull #165

github

web-flow
[skip-ci] refactor CheckGroupMembersFields (#197)
Pull Request #165: Implements group based census creation

248 of 392 new or added lines in 9 files covered. (63.27%)

5 existing lines in 4 files now uncovered.

5188 of 9001 relevant lines covered (57.64%)

26.32 hits per line

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

88.89
/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(groupID string, orgAddress common.Address) (*OrganizationMemberGroup, error) {
63✔
20
        if orgAddress.Cmp(common.Address{}) == 0 {
64✔
21
                return nil, ErrInvalidData
1✔
22
        }
1✔
23
        objID, err := primitive.ObjectIDFromHex(groupID)
62✔
24
        if err != nil {
67✔
25
                return nil, ErrInvalidData
5✔
26
        }
5✔
27

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

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

7✔
57
        filter := bson.M{"orgAddress": orgAddress}
7✔
58

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

7✔
66
        // Calculate skip value based on page and pageSize
7✔
67
        skip := (page - 1) * pageSize
7✔
68

7✔
69
        // Set up options for pagination
7✔
70
        findOptions := options.Find().
7✔
71
                SetSkip(int64(skip)).
7✔
72
                SetLimit(int64(pageSize))
7✔
73

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

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

94
        if err := cursor.Err(); err != nil {
7✔
95
                return 0, nil, err
×
96
        }
×
97

98
        return totalPages, groups, nil
7✔
99
}
100

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

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

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

35✔
129
        // Only lock the mutex during the actual database operations
35✔
130
        ms.keysLock.Lock()
35✔
131
        defer ms.keysLock.Unlock()
35✔
132

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

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

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

173
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
6✔
174

6✔
175
        // Only lock the mutex during the actual database operations
6✔
176
        ms.keysLock.Lock()
6✔
177
        defer ms.keysLock.Unlock()
6✔
178

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

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

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

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

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

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

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

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

234
        return nil
6✔
235
}
236

237
// DeleteOrganizationMemberGroup deletes an organization member group by its ID
238
func (ms *MongoStorage) DeleteOrganizationMemberGroup(groupID string, orgAddress common.Address) error {
7✔
239
        if orgAddress.Cmp(common.Address{}) == 0 {
8✔
240
                return ErrInvalidData
1✔
241
        }
1✔
242
        // create a context with a timeout
243
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
6✔
244
        defer cancel()
6✔
245

6✔
246
        objID, err := primitive.ObjectIDFromHex(groupID)
6✔
247
        if err != nil {
7✔
248
                return fmt.Errorf("invalid group ID: %w", err)
1✔
249
        }
1✔
250

251
        // Only lock the mutex during the actual database operations
252
        ms.keysLock.Lock()
5✔
253
        defer ms.keysLock.Unlock()
5✔
254

5✔
255
        // delete the group from the database
5✔
256
        filter := bson.M{"_id": objID, "orgAddress": orgAddress}
5✔
257
        if _, err := ms.orgMemberGroups.DeleteOne(ctx, filter); err != nil {
5✔
258
                return fmt.Errorf("could not delete organization members group: %w", err)
×
259
        }
×
260
        return nil
5✔
261
}
262

263
// ListOrganizationMemberGroup lists all the members of an organization member group and the total number of members
264
func (ms *MongoStorage) ListOrganizationMemberGroup(
265
        groupID string, orgAddress common.Address,
266
        page, pageSize int64,
267
) (int, []*OrgMember, error) {
10✔
268
        if orgAddress.Cmp(common.Address{}) == 0 {
11✔
269
                return 0, nil, ErrInvalidData
1✔
270
        }
1✔
271
        // get the group
272
        group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
9✔
273
        if err != nil {
12✔
274
                return 0, nil, fmt.Errorf("could not retrieve organization members group: %w", err)
3✔
275
        }
3✔
276

277
        return ms.orgMembersByIDs(
6✔
278
                orgAddress,
6✔
279
                group.MemberIDs,
6✔
280
                page,
6✔
281
                pageSize,
6✔
282
        )
6✔
283
}
284

285
// CheckOrgMemberAuthFields checks if the provided orgFields are valid for authentication
286
// Checks the entire member base of an organization creating a projection that contains only
287
// the provided auth fields and verifies that the resulting data do not have duplicates or
288
// missing fields. Returns the corrsponding informative errors concerning duplicates or columns with empty values
289
// The authFields are checked for missing data and duplicates while the twoFaFields are only checked for missing data
290
func (ms *MongoStorage) CheckGroupMembersFields(
291
        orgAddress common.Address,
292
        groupID string,
293
        authFields OrgMemberAuthFields,
294
        twoFaFields OrgMemberTwoFaFields,
295
) (*OrgMemberAggregationResults, error) {
19✔
296
        if orgAddress.Cmp(common.Address{}) == 0 {
20✔
297
                return nil, ErrInvalidData
1✔
298
        }
1✔
299
        if len(authFields) == 0 && len(twoFaFields) == 0 {
19✔
300
                return nil, fmt.Errorf("no auth or twoFa fields provided")
1✔
301
        }
1✔
302

303
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
17✔
304
        defer cancel()
17✔
305

17✔
306
        // 2) Fetch all matching docs
17✔
307
        cur, err := ms.getGroupMembersFields(ctx, orgAddress, groupID, authFields, twoFaFields)
17✔
308
        if err != nil {
20✔
309
                return nil, err
3✔
310
        }
3✔
311
        defer func() {
28✔
312
                if err := cur.Close(ctx); err != nil {
14✔
NEW
313
                        log.Warnw("error closing cursor", "error", err)
×
NEW
314
                }
×
315
        }()
316

317
        results := OrgMemberAggregationResults{
14✔
318
                Members:     make([]primitive.ObjectID, 0),
14✔
319
                Duplicates:  make([]primitive.ObjectID, 0),
14✔
320
                MissingData: make([]primitive.ObjectID, 0),
14✔
321
        }
14✔
322

14✔
323
        seenKeys := make(map[string]primitive.ObjectID, cur.RemainingBatchLength())
14✔
324
        duplicates := make(map[primitive.ObjectID]struct{}, 0)
14✔
325

14✔
326
        // 4) Iterate and detect
14✔
327
        for cur.Next(ctx) {
59✔
328
                // decode into a map so we can handle dynamic fields
45✔
329
                var m OrgMember
45✔
330
                var bm bson.M
45✔
331
                if err := cur.Decode(&m); err != nil {
45✔
NEW
332
                        return nil, err
×
NEW
333
                }
×
334
                if err := cur.Decode(&bm); err != nil {
45✔
NEW
335
                        return nil, err
×
NEW
336
                }
×
337

338
                // if any of the fields are empty, add to missing data
339
                // and continue to the next member
340
                // we do not check for duplicates in empty rows
341
                if hasEmptyFields(bm, authFields) || hasEmptyFields(bm, twoFaFields) {
51✔
342
                        results.MissingData = append(results.MissingData, m.ID)
6✔
343
                        continue
6✔
344
                }
345

346
                // if the key is already seen, add to duplicates
347
                // and continue to the next member
348
                key := buildKey(bm, authFields)
39✔
349
                if val, seen := seenKeys[key]; seen {
53✔
350
                        duplicates[m.ID] = struct{}{}
14✔
351
                        duplicates[val] = struct{}{}
14✔
352
                        continue
14✔
353
                }
354

355
                // neither empty nor duplicate, so we add it to the seen keys
356
                seenKeys[key] = m.ID
25✔
357
                // append the member ID to the results
25✔
358
                results.Members = append(results.Members, m.ID)
25✔
359
        }
360
        if err := cur.Err(); err != nil {
14✔
NEW
361
                return nil, err
×
NEW
362
        }
×
363
        results.Duplicates = mapKeysToSlice(duplicates)
14✔
364

14✔
365
        return &results, nil
14✔
366
}
367

368
// getGroupMembersAuthFields creates a projection of a set of members that
369
// contains only the chosen AuthFields
370
func (ms *MongoStorage) getGroupMembersFields(
371
        ctx context.Context,
372
        orgAddress common.Address,
373
        groupID string,
374
        authFields OrgMemberAuthFields,
375
        twoFaFields OrgMemberTwoFaFields,
376
) (*mongo.Cursor, error) {
17✔
377
        // 1) Build find filter and projection
17✔
378
        filter := bson.D{
17✔
379
                {Key: "orgAddress", Value: orgAddress},
17✔
380
        }
17✔
381
        // in case a groupID is provided, fetch the group and its members and
17✔
382
        // extend the filter to include only those members
17✔
383
        if len(groupID) > 0 {
31✔
384
                group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
14✔
385
                if err != nil {
17✔
386
                        if err == ErrNotFound {
5✔
387
                                return nil, fmt.Errorf("group %s not found for organization %s: %w", groupID, orgAddress, ErrInvalidData)
2✔
388
                        }
2✔
389
                        return nil, fmt.Errorf("failed to fetch group %s for organization %s: %w", groupID, orgAddress, err)
1✔
390
                }
391
                // Check if the group has members
392
                if len(group.MemberIDs) == 0 {
11✔
NEW
393
                        return nil, fmt.Errorf("no members in group %s for organization %s", groupID, orgAddress)
×
NEW
394
                }
×
395
                objectIDs := make([]primitive.ObjectID, len(group.MemberIDs))
11✔
396
                for i, id := range group.MemberIDs {
54✔
397
                        objID, err := primitive.ObjectIDFromHex(id)
43✔
398
                        if err != nil {
43✔
NEW
399
                                return nil, fmt.Errorf("invalid member ID %s: %w", id, ErrInvalidData)
×
NEW
400
                        }
×
401
                        objectIDs[i] = objID
43✔
402
                }
403
                if len(objectIDs) > 0 {
22✔
404
                        filter = append(filter, bson.E{Key: "_id", Value: bson.M{"$in": objectIDs}})
11✔
405
                }
11✔
406
        }
407

408
        proj := bson.D{
14✔
409
                {Key: "_id", Value: 1},
14✔
410
                {Key: "orgAddress", Value: 1},
14✔
411
        }
14✔
412
        // Add the authFields and twoFaFields to the projection
14✔
413
        for _, f := range authFields {
28✔
414
                proj = append(proj, bson.E{Key: string(f), Value: 1})
14✔
415
        }
14✔
416
        for _, f := range twoFaFields {
22✔
417
                proj = append(proj, bson.E{Key: string(f), Value: 1})
8✔
418
        }
8✔
419
        findOpts := options.Find().SetProjection(proj)
14✔
420

14✔
421
        // 2) Fetch all matching docs
14✔
422
        return ms.orgMembers.Find(ctx, filter, findOpts)
14✔
423
}
424

425
// Helper function to check if a string is in a slice
426
func contains(slice []string, item string) bool {
16✔
427
        for _, s := range slice {
35✔
428
                if s == item {
26✔
429
                        return true
7✔
430
                }
7✔
431
        }
432
        return false
9✔
433
}
434

435
// mapKeysToSlice extracts all keys from a map as a slice.
436
func mapKeysToSlice[T comparable, V any](m map[T]V) []T {
14✔
437
        keys := make([]T, 0, len(m))
14✔
438
        for k := range m {
34✔
439
                keys = append(keys, k)
20✔
440
        }
20✔
441
        return keys
14✔
442
}
443

444
// hasEmptyFields returns true if any of the specified fields in the BSON document are empty or nil.
445
func hasEmptyFields[T ~string](bm bson.M, fields []T) bool {
87✔
446
        for _, f := range fields {
152✔
447
                val := fmt.Sprint(bm[string(f)])
65✔
448
                if val == "" || bm[string(f)] == nil {
71✔
449
                        return true
6✔
450
                }
6✔
451
        }
452
        return false
81✔
453
}
454

455
// buildKey constructs a composite key from the values of specified fields in the BSON document.
456
// The values are concatenated with "|" as a delimiter.
457
func buildKey[T ~string](bm bson.M, fields []T) string {
39✔
458
        keyParts := make([]string, len(fields))
39✔
459
        for i, f := range fields {
83✔
460
                keyParts[i] = fmt.Sprint(bm[string(f)])
44✔
461
        }
44✔
462
        return strings.Join(keyParts, "|")
39✔
463
}
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