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

vocdoni / saas-backend / 17557469823

08 Sep 2025 04:25PM UTC coverage: 58.777% (-0.06%) from 58.841%
17557469823

Pull #213

github

altergui
fix
Pull Request #213: api: standardize parameters ProcessID, CensusID, GroupID, JobID, UserID, BundleID

254 of 345 new or added lines in 22 files covered. (73.62%)

19 existing lines in 7 files now uncovered.

5652 of 9616 relevant lines covered (58.78%)

32.01 hits per line

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

86.39
/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
        "github.com/vocdoni/saas-backend/internal"
12
        "go.mongodb.org/mongo-driver/bson"
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 internal.ObjectID,
21
        orgAddress common.Address,
22
) (*OrganizationMemberGroup, error) {
87✔
23
        if orgAddress.Cmp(common.Address{}) == 0 {
88✔
24
                return nil, ErrInvalidData
1✔
25
        }
1✔
26
        if groupID.IsZero() {
86✔
UNCOV
27
                return nil, ErrInvalidData
×
UNCOV
28
        }
×
29

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

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

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

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

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

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

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

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

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

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

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

46✔
109
        if group == nil || group.OrgAddress.Cmp(common.Address{}) == 0 || len(group.MemberIDs) == 0 {
48✔
110
                return internal.NilObjectID, ErrInvalidData
2✔
111
        }
2✔
112

113
        // check that the organization exists
114
        if _, err := ms.fetchOrganizationFromDB(ctx, group.OrgAddress); err != nil {
45✔
115
                if err == ErrNotFound {
2✔
116
                        return internal.NilObjectID, ErrInvalidData
1✔
117
                }
1✔
NEW
118
                return internal.NilObjectID, fmt.Errorf("organization not found: %w", err)
×
119
        }
120

121
        // check that the members are valid
122
        err := ms.validateOrgMembers(ctx, group.OrgAddress, group.MemberIDs)
43✔
123
        if err != nil {
44✔
124
                return internal.NilObjectID, err
1✔
125
        }
1✔
126
        // create the group id
127
        group.ID = internal.NewObjectID()
42✔
128
        group.CreatedAt = time.Now()
42✔
129
        group.UpdatedAt = time.Now()
42✔
130
        group.CensusIDs = make([]internal.ObjectID, 0)
42✔
131

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

42✔
136
        // insert the group into the database
42✔
137
        if _, err := ms.orgMemberGroups.InsertOne(ctx, *group); err != nil {
42✔
NEW
138
                return internal.NilObjectID, fmt.Errorf("could not create organization members group: %w", err)
×
139
        }
×
140
        return group.ID, nil
42✔
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 internal.ObjectID, orgAddress common.Address,
148
        title, description string, addedMembers, removedMembers []internal.ObjectID,
149
) error {
10✔
150
        if orgAddress.Cmp(common.Address{}) == 0 {
11✔
151
                return ErrInvalidData
1✔
152
        }
1✔
153
        if groupID.IsZero() {
9✔
NEW
154
                return ErrInvalidData
×
NEW
155
        }
×
156
        group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
9✔
157
        if err != nil {
11✔
158
                if err == ErrNotFound {
4✔
159
                        return ErrInvalidData
2✔
160
                }
2✔
UNCOV
161
                return fmt.Errorf("could not retrieve organization members group: %w", err)
×
162
        }
163
        // create a context with a timeout
164
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
7✔
165
        defer cancel()
7✔
166

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

175
        filter := bson.M{"_id": groupID, "orgAddress": orgAddress}
6✔
176

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

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

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

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

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

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

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

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

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

236
        return nil
6✔
237
}
238

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

247
        // update the group with the census ID
248
        filter := bson.M{"_id": groupID, "orgAddress": orgAddress}
11✔
249
        update := bson.D{{Key: "$addToSet", Value: bson.M{"censusIds": censusID}}}
11✔
250
        _, err := ms.orgMemberGroups.UpdateOne(ctx, filter, update)
11✔
251
        return err
11✔
252
}
253

254
// DeleteOrganizationMemberGroup deletes an organization member group by its ID
255
func (ms *MongoStorage) DeleteOrganizationMemberGroup(groupID internal.ObjectID, orgAddress common.Address) error {
8✔
256
        if orgAddress.Cmp(common.Address{}) == 0 {
9✔
257
                return ErrInvalidData
1✔
258
        }
1✔
259
        if groupID.IsZero() {
7✔
NEW
260
                return ErrInvalidData
×
NEW
261
        }
×
262
        // create a context with a timeout
263
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
7✔
264
        defer cancel()
7✔
265

7✔
266
        // Only lock the mutex during the actual database operations
7✔
267
        ms.keysLock.Lock()
7✔
268
        defer ms.keysLock.Unlock()
7✔
269

7✔
270
        // delete the group from the database
7✔
271
        filter := bson.M{"_id": groupID, "orgAddress": orgAddress}
7✔
272
        if _, err := ms.orgMemberGroups.DeleteOne(ctx, filter); err != nil {
7✔
273
                return fmt.Errorf("could not delete organization members group: %w", err)
×
274
        }
×
275
        return nil
7✔
276
}
277

278
// ListOrganizationMemberGroup lists all the members of an organization member group and the total number of members
279
func (ms *MongoStorage) ListOrganizationMemberGroup(
280
        groupID internal.ObjectID, orgAddress common.Address,
281
        page, pageSize int64,
282
) (int, []*OrgMember, error) {
22✔
283
        if orgAddress.Cmp(common.Address{}) == 0 {
23✔
284
                return 0, nil, ErrInvalidData
1✔
285
        }
1✔
286
        if groupID.IsZero() {
21✔
NEW
287
                return 0, nil, ErrInvalidData
×
NEW
288
        }
×
289
        // get the group
290
        group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
21✔
291
        if err != nil {
24✔
292
                return 0, nil, fmt.Errorf("could not retrieve organization members group: %w", err)
3✔
293
        }
3✔
294

295
        return ms.orgMembersByIDs(
18✔
296
                orgAddress,
18✔
297
                group.MemberIDs,
18✔
298
                page,
18✔
299
                pageSize,
18✔
300
        )
18✔
301
}
302

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

321
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
18✔
322
        defer cancel()
18✔
323

18✔
324
        // 2) Fetch all matching docs
18✔
325
        cur, err := ms.getGroupMembersFields(ctx, orgAddress, groupID, authFields, twoFaFields)
18✔
326
        if err != nil {
21✔
327
                return nil, err
3✔
328
        }
3✔
329
        defer func() {
30✔
330
                if err := cur.Close(ctx); err != nil {
15✔
331
                        log.Warnw("error closing cursor", "error", err)
×
332
                }
×
333
        }()
334

335
        results := OrgMemberAggregationResults{
15✔
336
                Members:     make([]internal.ObjectID, 0),
15✔
337
                Duplicates:  make([]internal.ObjectID, 0),
15✔
338
                MissingData: make([]internal.ObjectID, 0),
15✔
339
        }
15✔
340

15✔
341
        seenKeys := make(map[string]internal.ObjectID, cur.RemainingBatchLength())
15✔
342
        duplicates := make(map[internal.ObjectID]struct{}, 0)
15✔
343

15✔
344
        // 4) Iterate and detect
15✔
345
        for cur.Next(ctx) {
70✔
346
                // decode into a map so we can handle dynamic fields
55✔
347
                var m OrgMember
55✔
348
                var bm bson.M
55✔
349
                if err := cur.Decode(&m); err != nil {
55✔
350
                        return nil, err
×
351
                }
×
352
                if err := cur.Decode(&bm); err != nil {
55✔
353
                        return nil, err
×
354
                }
×
355

356
                // if any of the fields are empty, add to missing data
357
                // and continue to the next member
358
                // we do not check for duplicates in empty rows
359
                if hasEmptyFields(bm, authFields) || hasEmptyFields(bm, twoFaFields) {
61✔
360
                        results.MissingData = append(results.MissingData, m.ID)
6✔
361
                        continue
6✔
362
                }
363

364
                // if the key is already seen, add to duplicates
365
                // and continue to the next member
366
                if len(authFields) > 0 {
86✔
367
                        key := buildCompositeKey(bm, authFields, twoFaFields)
37✔
368
                        if val, seen := seenKeys[key]; seen {
42✔
369
                                duplicates[m.ID] = struct{}{}
5✔
370
                                duplicates[val] = struct{}{}
5✔
371
                                continue
5✔
372
                        }
373
                        // neither empty nor duplicate, so we add it to the seen keys
374
                        seenKeys[key] = m.ID
32✔
375
                }
376

377
                // if thedata pass all checkss  append the member ID to the results
378
                results.Members = append(results.Members, m.ID)
44✔
379
        }
380
        if err := cur.Err(); err != nil {
15✔
381
                return nil, err
×
382
        }
×
383
        results.Duplicates = mapKeysToSlice(duplicates)
15✔
384

15✔
385
        return &results, nil
15✔
386
}
387

388
// getGroupMembersAuthFields creates a projection of a set of members that
389
// contains only the chosen AuthFields
390
func (ms *MongoStorage) getGroupMembersFields(
391
        ctx context.Context,
392
        orgAddress common.Address,
393
        groupID internal.ObjectID,
394
        authFields OrgMemberAuthFields,
395
        twoFaFields OrgMemberTwoFaFields,
396
) (*mongo.Cursor, error) {
18✔
397
        // 1) Build find filter and projection
18✔
398
        filter := bson.D{
18✔
399
                {Key: "orgAddress", Value: orgAddress},
18✔
400
        }
18✔
401
        // in case a groupID is provided, fetch the group and its members and
18✔
402
        // extend the filter to include only those members
18✔
403
        if !groupID.IsZero() {
36✔
404
                group, err := ms.OrganizationMemberGroup(groupID, orgAddress)
18✔
405
                if err != nil {
21✔
406
                        if err == ErrNotFound {
6✔
407
                                return nil, fmt.Errorf("group %s not found for organization %s: %w", groupID, orgAddress, ErrInvalidData)
3✔
408
                        }
3✔
UNCOV
409
                        return nil, fmt.Errorf("failed to fetch group %s for organization %s: %w", groupID, orgAddress, err)
×
410
                }
411
                // Check if the group has members
412
                if len(group.MemberIDs) == 0 {
15✔
413
                        return nil, fmt.Errorf("no members in group %s for organization %s", groupID, orgAddress)
×
414
                }
×
415
                objectIDs := group.MemberIDs
15✔
416
                if len(objectIDs) > 0 {
30✔
417
                        filter = append(filter, bson.E{Key: "_id", Value: bson.M{"$in": objectIDs}})
15✔
418
                }
15✔
419
        }
420

421
        proj := bson.D{
15✔
422
                {Key: "_id", Value: 1},
15✔
423
                {Key: "orgAddress", Value: 1},
15✔
424
        }
15✔
425
        // Add the authFields and twoFaFields to the projection
15✔
426
        for _, f := range authFields {
32✔
427
                proj = append(proj, bson.E{Key: string(f), Value: 1})
17✔
428
        }
17✔
429
        for _, f := range twoFaFields {
25✔
430
                proj = append(proj, bson.E{Key: string(f), Value: 1})
10✔
431
        }
10✔
432
        findOpts := options.Find().SetProjection(proj)
15✔
433

15✔
434
        // 2) Fetch all matching docs
15✔
435
        return ms.orgMembers.Find(ctx, filter, findOpts)
15✔
436
}
437

438
// Helper function to check if an item is in a slice (generic version)
439
func contains[T comparable](slice []T, item T) bool {
16✔
440
        for _, s := range slice {
35✔
441
                if s == item {
26✔
442
                        return true
7✔
443
                }
7✔
444
        }
445
        return false
9✔
446
}
447

448
// mapKeysToSlice extracts all keys from a map as a slice.
449
func mapKeysToSlice[T comparable, V any](m map[T]V) []T {
15✔
450
        keys := make([]T, 0, len(m))
15✔
451
        for k := range m {
25✔
452
                keys = append(keys, k)
10✔
453
        }
10✔
454
        return keys
15✔
455
}
456

457
// hasEmptyFields returns true if any of the specified fields in the BSON document are empty or nil.
458
func hasEmptyFields[T ~string](bm bson.M, fields []T) bool {
106✔
459
        for _, f := range fields {
201✔
460
                val := fmt.Sprint(bm[string(f)])
95✔
461
                if val == "" || bm[string(f)] == nil {
101✔
462
                        return true
6✔
463
                }
6✔
464
        }
465
        return false
100✔
466
}
467

468
// buildCompositeKey constructs a composite key from both auth and 2FA fields.
469
// Values are concatenated with "|" as delimiter, auth fields first, then 2FA fields.
470
func buildCompositeKey(bm bson.M, authFields OrgMemberAuthFields, twoFaFields OrgMemberTwoFaFields) string {
37✔
471
        totalFields := len(authFields) + len(twoFaFields)
37✔
472
        if totalFields == 0 {
37✔
473
                return ""
×
474
        }
×
475

476
        keyParts := make([]string, 0, totalFields)
37✔
477

37✔
478
        // Add auth field values
37✔
479
        for _, f := range authFields {
93✔
480
                keyParts = append(keyParts, fmt.Sprint(bm[string(f)]))
56✔
481
        }
56✔
482

483
        // Add 2FA field values
484
        for _, f := range twoFaFields {
54✔
485
                keyParts = append(keyParts, fmt.Sprint(bm[string(f)]))
17✔
486
        }
17✔
487

488
        return strings.Join(keyParts, "|")
37✔
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