• 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

70.94
/api/org_members.go
1
package api
2

3
import (
4
        "encoding/json"
5
        "net/http"
6
        "strconv"
7
        "sync"
8
        "time"
9

10
        "github.com/vocdoni/saas-backend/api/apicommon"
11
        "github.com/vocdoni/saas-backend/db"
12
        "github.com/vocdoni/saas-backend/errors"
13
        "github.com/vocdoni/saas-backend/internal"
14
        "go.vocdoni.io/dvote/log"
15
)
16

17
// addMembersToOrgWorkers is a map of job identifiers to the progress of adding members to a census.
18
// This is used to check the progress of the job.
19
var addMembersToOrgWorkers sync.Map
20

21
// organizationMembersHandler godoc
22
//
23
//        @Summary                Get organization members
24
//        @Description        Retrieve all members of an organization with pagination support
25
//        @Tags                        organizations
26
//        @Accept                        json
27
//        @Produce                json
28
//        @Security                BearerAuth
29
//        @Param                        address                path                string        true        "Organization address"
30
//        @Param                        page                query                integer        false        "Page number (default: 1)"
31
//        @Param                        pageSize        query                integer        false        "Number of items per page (default: 10)"
32
//        @Param                        search                query                string        false        "Search term for member properties"
33
//        @Success                200                        {object}        apicommon.OrganizationMembersResponse
34
//        @Failure                400                        {object}        errors.Error        "Invalid input"
35
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
36
//        @Failure                500                        {object}        errors.Error        "Internal server error"
37
//        @Router                        /organizations/{address}/members [get]
38
func (a *API) organizationMembersHandler(w http.ResponseWriter, r *http.Request) {
27✔
39
        // get the organization info from the request context
27✔
40
        org, _, ok := a.organizationFromRequest(r)
27✔
41
        if !ok {
28✔
42
                errors.ErrNoOrganizationProvided.Write(w)
1✔
43
                return
1✔
44
        }
1✔
45
        // get the user from the request context
46
        user, ok := apicommon.UserFromContext(r.Context())
26✔
47
        if !ok {
26✔
48
                errors.ErrUnauthorized.Write(w)
×
49
                return
×
50
        }
×
51
        // check the user has the necessary permissions
52
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
26✔
53
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
54
                return
×
55
        }
×
56

57
        // Parse pagination parameters from query string
58
        page := 1      // Default page number
26✔
59
        pageSize := 10 // Default page size
26✔
60
        search := ""   // Default search term
26✔
61

26✔
62
        if pageStr := r.URL.Query().Get("page"); pageStr != "" {
28✔
63
                if pageVal, err := strconv.Atoi(pageStr); err == nil && pageVal > 0 {
4✔
64
                        page = pageVal
2✔
65
                }
2✔
66
        }
67

68
        if pageSizeStr := r.URL.Query().Get("pageSize"); pageSizeStr != "" {
28✔
69
                if pageSizeVal, err := strconv.Atoi(pageSizeStr); err == nil && pageSizeVal >= 0 {
4✔
70
                        pageSize = pageSizeVal
2✔
71
                }
2✔
72
        }
73

74
        if searchStr := r.URL.Query().Get("search"); searchStr != "" {
34✔
75
                search = searchStr
8✔
76
        }
8✔
77

78
        // retrieve the orgMembers with pagination
79
        pages, members, err := a.db.OrgMembers(org.Address, page, pageSize, search)
26✔
80
        if err != nil {
26✔
81
                errors.ErrGenericInternalServerError.Withf("could not get org members: %v", err).Write(w)
×
82
                return
×
83
        }
×
84

85
        // convert the orgMembers to the response format
86
        membersResponse := make([]apicommon.OrgMember, 0, len(members))
26✔
87
        for _, p := range members {
116✔
88
                membersResponse = append(membersResponse, apicommon.OrgMemberFromDb(p))
90✔
89
        }
90✔
90

91
        apicommon.HTTPWriteJSON(w, &apicommon.OrganizationMembersResponse{
26✔
92
                Pages:   pages,
26✔
93
                Page:    page,
26✔
94
                Members: membersResponse,
26✔
95
        })
26✔
96
}
97

98
// addOrganizationMembersHandler godoc
99
//
100
//        @Summary                Add members to an organization
101
//        @Description        Add multiple members to an organization. Requires Manager/Admin role.
102
//        @Tags                        organizations
103
//        @Accept                        json
104
//        @Produce                json
105
//        @Security                BearerAuth
106
//        @Param                        address        path                string                                                true        "Organization address"
107
//        @Param                        async        query                boolean                                                false        "Process asynchronously and return job ID"
108
//        @Param                        request        body                apicommon.AddMembersRequest        true        "Members to add"
109
//        @Success                200                {object}        apicommon.AddMembersResponse
110
//        @Failure                400                {object}        errors.Error        "Invalid input data"
111
//        @Failure                401                {object}        errors.Error        "Unauthorized"
112
//        @Failure                500                {object}        errors.Error        "Internal server error"
113
//        @Router                        /organizations/{address}/members [post]
114
func (a *API) addOrganizationMembersHandler(w http.ResponseWriter, r *http.Request) {
19✔
115
        // get the organization info from the request context
19✔
116
        org, _, ok := a.organizationFromRequest(r)
19✔
117
        if !ok {
20✔
118
                errors.ErrNoOrganizationProvided.Write(w)
1✔
119
                return
1✔
120
        }
1✔
121
        // get the user from the request context
122
        user, ok := apicommon.UserFromContext(r.Context())
18✔
123
        if !ok {
18✔
124
                errors.ErrUnauthorized.Write(w)
×
125
                return
×
126
        }
×
127
        // get the async flag
128
        async := r.URL.Query().Get("async") == "true"
18✔
129

18✔
130
        // retrieve census
18✔
131
        // check the user has the necessary permissions
18✔
132
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
18✔
133
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
134
                return
×
135
        }
×
136
        // decode the members from the request body
137
        members := &apicommon.AddMembersRequest{}
18✔
138
        if err := json.NewDecoder(r.Body).Decode(members); err != nil {
18✔
139
                log.Error(err)
×
140
                errors.ErrMalformedBody.Withf("missing members").Write(w)
×
141
                return
×
142
        }
×
143
        // check if there are members to add
144
        if len(members.Members) == 0 {
19✔
145
                apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{Added: 0})
1✔
146
                return
1✔
147
        }
1✔
148
        // add the org members to the database
149
        progressChan, err := a.db.SetBulkOrgMembers(org, members.ToDB(), passwordSalt)
17✔
150
        if err != nil {
17✔
151
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
152
                return
×
153
        }
×
154

155
        if !async {
32✔
156
                // Wait for the channel to be closed (100% completion)
15✔
157
                var lastProgress *db.BulkOrgMembersJob
15✔
158
                for p := range progressChan {
45✔
159
                        lastProgress = p
30✔
160
                        // Just drain the channel until it's closed
30✔
161
                        log.Debugw("org add members",
30✔
162
                                "org", org.Address,
30✔
163
                                "progress", p.Progress,
30✔
164
                                "added", p.Added,
30✔
165
                                "total", p.Total,
30✔
166
                                "errors", len(p.Errors))
30✔
167
                }
30✔
168
                // Return the number of members added
169
                apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{
15✔
170
                        Added:  uint32(lastProgress.Added),
15✔
171
                        Errors: lastProgress.ErrorsAsStrings(),
15✔
172
                })
15✔
173
                return
15✔
174
        }
175

176
        // if async create a new job identifier
177
        jobID := internal.NewObjectID()
2✔
178

2✔
179
        // Create persistent job record
2✔
180
        if err := a.db.CreateJob(jobID, db.JobTypeOrgMembers, org.Address, len(members.Members)); err != nil {
2✔
NEW
181
                log.Warnw("failed to create persistent job record", "error", err, "jobId", jobID)
×
182
                // Continue with in-memory only (fallback)
×
183
        }
×
184

185
        go func() {
4✔
186
                for p := range progressChan {
6✔
187
                        // Store progress updates in a map that is read by another endpoint to check a job status
4✔
188
                        addMembersToOrgWorkers.Store(jobID, p)
4✔
189

4✔
190
                        // When job completes, persist final results
4✔
191
                        if p.Progress == 100 {
6✔
192
                                if err := a.db.CompleteJob(jobID, p.Added, p.ErrorsAsStrings()); err != nil {
2✔
NEW
193
                                        log.Warnw("failed to persist job completion", "error", err, "jobId", jobID)
×
UNCOV
194
                                }
×
195
                        }
196
                }
197
        }()
198

199
        apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{JobID: jobID})
2✔
200
}
201

202
// addOrganizationMembersJobStatusHandler godoc
203
//
204
//        @Summary                Check the progress of adding members
205
//        @Description        Check the progress of a job to add members to an organization. Returns the progress of the job.
206
//        @Description        If the job is completed, the job is deleted after 60 seconds.
207
//        @Tags                        organizations
208
//        @Accept                        json
209
//        @Produce                json
210
//        @Security                BearerAuth
211
//        @Param                        address        path                string        true        "Organization address"
212
//        @Param                        jobId        path                string        true        "Job ID"
213
//        @Success                200                {object}        apicommon.AddMembersJobResponse
214
//        @Failure                400                {object}        errors.Error        "Invalid job ID"
215
//        @Failure                401                {object}        errors.Error        "Unauthorized"
216
//        @Failure                404                {object}        errors.Error        "Job not found"
217
//        @Router                        /organizations/{address}/members/job/{jobId} [get]
218
func (a *API) addOrganizationMembersJobStatusHandler(w http.ResponseWriter, r *http.Request) {
6✔
219
        jobID, err := apicommon.JobIDFromRequest(r)
6✔
220
        if err != nil {
6✔
NEW
221
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
222
                return
×
223
        }
×
224

225
        // First check in-memory for active jobs
226
        if v, ok := addMembersToOrgWorkers.Load(jobID); ok {
12✔
227
                p, ok := v.(*db.BulkOrgMembersJob)
6✔
228
                if !ok {
6✔
229
                        errors.ErrGenericInternalServerError.Withf("invalid job status type").Write(w)
×
230
                        return
×
231
                }
×
232
                if p.Progress == 100 {
8✔
233
                        go func() {
4✔
234
                                // Schedule the deletion of the job after 60 seconds
2✔
235
                                time.Sleep(60 * time.Second)
2✔
236
                                addMembersToOrgWorkers.Delete(jobID)
2✔
237
                        }()
2✔
238
                }
239
                apicommon.HTTPWriteJSON(w, apicommon.AddMembersJobResponse{
6✔
240
                        Added:    uint32(p.Added),
6✔
241
                        Errors:   p.ErrorsAsStrings(),
6✔
242
                        Progress: uint32(p.Progress),
6✔
243
                        Total:    uint32(p.Total),
6✔
244
                })
6✔
245
                return
6✔
246
        }
247

248
        // If not in memory, check database for completed jobs
NEW
249
        job, err := a.db.Job(jobID)
×
250
        if err != nil {
×
251
                if err == db.ErrNotFound {
×
NEW
252
                        errors.ErrJobNotFound.Withf("%s", jobID).Write(w)
×
253
                        return
×
254
                }
×
255
                errors.ErrGenericInternalServerError.Withf("failed to get job: %v", err).Write(w)
×
256
                return
×
257
        }
258

259
        // Return persistent job data
260
        apicommon.HTTPWriteJSON(w, apicommon.AddMembersJobResponse{
×
261
                Added:    uint32(job.Added),
×
262
                Errors:   job.Errors,
×
263
                Progress: 100, // Completed jobs are always 100%
×
264
                Total:    uint32(job.Total),
×
265
        })
×
266
}
267

268
// deleteOrganizationMembersHandler godoc
269
//
270
//        @Summary                Delete organization members
271
//        @Description        Delete multiple members from an organization or all members. Requires Manager/Admin role.
272
//        @Tags                        organizations
273
//        @Accept                        json
274
//        @Produce                json
275
//        @Security                BearerAuth
276
//        @Param                        address        path                string                                                        true        "Organization address"
277
//        @Param                        request        body                apicommon.DeleteMembersRequest        true        "Member IDs to delete or all flag"
278
//        @Success                200                {object}        apicommon.DeleteMembersResponse
279
//        @Failure                400                {object}        errors.Error        "Invalid input data"
280
//        @Failure                401                {object}        errors.Error        "Unauthorized"
281
//        @Failure                500                {object}        errors.Error        "Internal server error"
282
//        @Router                        /organizations/{address}/member [delete]
283
func (a *API) deleteOrganizationMembersHandler(w http.ResponseWriter, r *http.Request) {
8✔
284
        // get the organization info from the request context
8✔
285
        org, _, ok := a.organizationFromRequest(r)
8✔
286
        if !ok {
9✔
287
                errors.ErrNoOrganizationProvided.Write(w)
1✔
288
                return
1✔
289
        }
1✔
290
        // get the user from the request context
291
        user, ok := apicommon.UserFromContext(r.Context())
7✔
292
        if !ok {
7✔
293
                errors.ErrUnauthorized.Write(w)
×
294
                return
×
295
        }
×
296
        // check the user has the necessary permissions
297
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
8✔
298
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
1✔
299
                return
1✔
300
        }
1✔
301
        // get memberIds from the request body
302
        members := &apicommon.DeleteMembersRequest{}
6✔
303
        if err := json.NewDecoder(r.Body).Decode(members); err != nil {
6✔
304
                errors.ErrMalformedBody.Withf("error decoding member request").Write(w)
×
305
                return
×
306
        }
×
307

308
        var deleted int
6✔
309
        var err error
6✔
310

6✔
311
        // check if we should delete all members
6✔
312
        if members.All {
8✔
313
                // delete all org members from the database
2✔
314
                deleted, err = a.db.DeleteAllOrgMembers(org.Address)
2✔
315
                if err != nil {
2✔
316
                        errors.ErrGenericInternalServerError.Withf("could not delete all org members: %v", err).Write(w)
×
317
                        return
×
318
                }
×
319
                log.Infow("deleted all organization members",
2✔
320
                        "org", org.Address.Hex(),
2✔
321
                        "count", deleted,
2✔
322
                        "user", user.Email)
2✔
323
        } else {
4✔
324
                // check if there are member IDs to delete
4✔
325
                if len(members.IDs) == 0 {
5✔
326
                        apicommon.HTTPWriteJSON(w, &apicommon.DeleteMembersResponse{Count: 0})
1✔
327
                        return
1✔
328
                }
1✔
329
                // delete specific org members from the database
330
                deleted, err = a.db.DeleteOrgMembers(org.Address, members.IDs)
3✔
331
                if err != nil {
3✔
332
                        errors.ErrGenericInternalServerError.Withf("could not delete org members: %v", err).Write(w)
×
333
                        return
×
334
                }
×
335
        }
336

337
        apicommon.HTTPWriteJSON(w, &apicommon.DeleteMembersResponse{Count: deleted})
5✔
338
}
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