• 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

69.42
/api/census.go
1
package api
2

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

9
        "github.com/go-chi/chi/v5"
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
        "go.vocdoni.io/dvote/util"
16
)
17

18
const (
19
        // CensusTypeSMSOrMail is the CSP based type of census that supports both SMS and mail.
20
        CensusTypeSMSOrMail = "sms_or_mail"
21
        CensusTypeMail      = "mail"
22
        CensusTypeSMS       = "sms"
23
)
24

25
// addParticipantsToCensusWorkers is a map of job identifiers to the progress of adding participants to a census.
26
// This is used to check the progress of the job.
27
var addParticipantsToCensusWorkers sync.Map
28

29
// createCensusHandler godoc
30
//
31
//        @Summary                Create a new census
32
//        @Description        Create a new census for an organization. Requires Manager/Admin role.
33
//        @Description        Creates either a regular census or a group-based census if GroupID is provided.
34
//        @Description        Validates that either AuthFields or TwoFaFields are provided and checks for duplicates or empty fields.
35
//        @Tags                        census
36
//        @Accept                        json
37
//        @Produce                json
38
//        @Security                BearerAuth
39
//        @Param                        request        body                apicommon.CreateCensusRequest        true        "Census information"
40
//        @Success                200                {object}        apicommon.CreateCensusResponse        "Returns the created census ID"
41
//        @Failure                400                {object}        errors.Error                                        "Invalid input data or missing required fields"
42
//        @Failure                401                {object}        errors.Error                                        "Unauthorized"
43
//        @Failure                500                {object}        errors.Error                                        "Internal server error"
44
//        @Router                        /census [post]
45
func (a *API) createCensusHandler(w http.ResponseWriter, r *http.Request) {
9✔
46
        // Parse request
9✔
47
        censusInfo := &apicommon.CreateCensusRequest{}
9✔
48
        if err := json.NewDecoder(r.Body).Decode(&censusInfo); err != nil {
9✔
49
                errors.ErrMalformedBody.Write(w)
×
50
                return
×
51
        }
×
52

53
        // get the user from the request context
54
        user, ok := apicommon.UserFromContext(r.Context())
9✔
55
        if !ok {
9✔
56
                errors.ErrUnauthorized.Write(w)
×
57
                return
×
58
        }
×
59

60
        // check the user has the necessary permissions
61
        if !user.HasRoleFor(censusInfo.OrgAddress, db.ManagerRole) && !user.HasRoleFor(censusInfo.OrgAddress, db.AdminRole) {
10✔
62
                errors.ErrUnauthorized.Withf("user does not have the necessary permissions in the organization").Write(w)
1✔
63
                return
1✔
64
        }
1✔
65

66
        if len(censusInfo.AuthFields) == 0 && len(censusInfo.TwoFaFields) == 0 {
9✔
67
                errors.ErrInvalidData.Withf("missing both AuthFields and TwoFaFields").Write(w)
1✔
68
                return
1✔
69
        }
1✔
70
        // check the org members to veriy tha the OrgMemberAuthFields can be used for authentication
71
        aggregationResults, err := a.db.CheckGroupMembersFields(
7✔
72
                censusInfo.OrgAddress,
7✔
73
                censusInfo.GroupID,
7✔
74
                censusInfo.AuthFields,
7✔
75
                censusInfo.TwoFaFields,
7✔
76
        )
7✔
77
        if err != nil {
8✔
78
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
1✔
79
                return
1✔
80
        }
1✔
81
        if len(aggregationResults.Duplicates) > 0 || len(aggregationResults.MissingData) > 0 {
8✔
82
                // if there are incorrect members, return an error with the IDs of the incorrect members
2✔
83
                errors.ErrInvalidCensusData.WithData(aggregationResults).Write(w)
2✔
84
                return
2✔
85
        }
2✔
86

87
        census := &db.Census{
4✔
88
                Type:        censusInfo.Type,
4✔
89
                OrgAddress:  censusInfo.OrgAddress,
4✔
90
                AuthFields:  censusInfo.AuthFields,
4✔
91
                TwoFaFields: censusInfo.TwoFaFields,
4✔
92
                CreatedAt:   time.Now(),
4✔
93
        }
4✔
94
        var censusID string
4✔
95
        if censusInfo.GroupID != "" {
5✔
96
                // In the group-based census, we need to be sure that there are members to be added
1✔
97
                if len(aggregationResults.Members) == 0 {
1✔
NEW
98
                        errors.ErrInvalidCensusData.Withf("no valid members found for the census").Write(w)
×
NEW
99
                        return
×
NEW
100
                }
×
101
                censusID, err = a.db.SetGroupCensus(census, censusInfo.GroupID, aggregationResults.Members)
1✔
102
        } else {
3✔
103
                // In the regular census, members will be added later so we just create the DB entry
3✔
104
                censusID, err = a.db.SetCensus(census)
3✔
105
        }
3✔
106
        if err != nil {
4✔
UNCOV
107
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
108
                return
×
109
        }
×
110

111
        apicommon.HTTPWriteJSON(w, apicommon.CreateCensusResponse{
4✔
112
                ID: censusID,
4✔
113
        })
4✔
114
}
115

116
// censusInfoHandler godoc
117
//
118
//        @Summary                Get census information
119
//        @Description        Retrieve census information by ID. Returns census type, organization address, and creation time.
120
//        @Tags                        census
121
//        @Accept                        json
122
//        @Produce                json
123
//        @Param                        id        path                string        true        "Census ID"
124
//        @Success                200        {object}        apicommon.OrganizationCensus
125
//        @Failure                400        {object}        errors.Error        "Invalid census ID"
126
//        @Failure                404        {object}        errors.Error        "Census not found"
127
//        @Failure                500        {object}        errors.Error        "Internal server error"
128
//        @Router                        /census/{id} [get]
129
func (a *API) censusInfoHandler(w http.ResponseWriter, r *http.Request) {
2✔
130
        censusID := internal.HexBytes{}
2✔
131
        if err := censusID.ParseString(chi.URLParam(r, "id")); err != nil {
3✔
132
                errors.ErrMalformedURLParam.Withf("wrong census ID").Write(w)
1✔
133
                return
1✔
134
        }
1✔
135
        census, err := a.db.Census(censusID.String())
1✔
136
        if err != nil {
1✔
137
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
138
                return
×
139
        }
×
140
        apicommon.HTTPWriteJSON(w, apicommon.OrganizationCensusFromDB(census))
1✔
141
}
142

143
// addCensusParticipantsHandler godoc
144
//
145
//        @Summary                Add participants to a census
146
//        @Description        Add multiple participants to a census. Requires Manager/Admin role.
147
//        @Tags                        census
148
//        @Accept                        json
149
//        @Produce                json
150
//        @Security                BearerAuth
151
//        @Param                        id                path                string                                                true        "Census ID"
152
//        @Param                        async        query                boolean                                                false        "Process asynchronously and return job ID"
153
//        @Param                        request        body                apicommon.AddMembersRequest        true        "Participants to add"
154
//        @Success                200                {object}        apicommon.AddMembersResponse
155
//        @Failure                400                {object}        errors.Error        "Invalid input data"
156
//        @Failure                401                {object}        errors.Error        "Unauthorized"
157
//        @Failure                404                {object}        errors.Error        "Census not found"
158
//        @Failure                500                {object}        errors.Error        "Internal server error"
159
//        @Router                        /census/{id} [post]
160
func (a *API) addCensusParticipantsHandler(w http.ResponseWriter, r *http.Request) {
6✔
161
        censusID := internal.HexBytes{}
6✔
162
        if err := censusID.ParseString(chi.URLParam(r, "id")); err != nil {
7✔
163
                errors.ErrMalformedURLParam.Withf("wrong census ID").Write(w)
1✔
164
                return
1✔
165
        }
1✔
166
        // get the user from the request context
167
        user, ok := apicommon.UserFromContext(r.Context())
5✔
168
        if !ok {
5✔
169
                errors.ErrUnauthorized.Write(w)
×
170
                return
×
171
        }
×
172
        // get the async flag
173
        async := r.URL.Query().Get("async") == "true"
5✔
174

5✔
175
        // retrieve census
5✔
176
        census, err := a.db.Census(censusID.String())
5✔
177
        if err != nil {
5✔
178
                if err == db.ErrNotFound {
×
179
                        errors.ErrMalformedURLParam.Withf("census not found").Write(w)
×
180
                        return
×
181
                }
×
182
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
183
                return
×
184
        }
185
        // check the user has the necessary permissions
186
        if !user.HasRoleFor(census.OrgAddress, db.ManagerRole) && !user.HasRoleFor(census.OrgAddress, db.AdminRole) {
5✔
187
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
188
                return
×
189
        }
×
190
        // decode the participants from the request body
191
        members := &apicommon.AddMembersRequest{}
5✔
192
        if err := json.NewDecoder(r.Body).Decode(members); err != nil {
5✔
193
                log.Error(err)
×
194
                errors.ErrMalformedBody.Withf("missing participants").Write(w)
×
195
                return
×
196
        }
×
197
        // check if there are participants to add
198
        if len(members.Members) == 0 {
6✔
199
                apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{Added: 0})
1✔
200
                return
1✔
201
        }
1✔
202
        // add the org members as census participants in the database
203
        progressChan, err := a.db.SetBulkCensusOrgMemberParticipant(
4✔
204
                passwordSalt,
4✔
205
                censusID.String(),
4✔
206
                members.DbOrgMembers(census.OrgAddress),
4✔
207
        )
4✔
208
        if err != nil {
4✔
209
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
210
                return
×
211
        }
×
212

213
        if !async {
7✔
214
                // Wait for the channel to be closed (100% completion)
3✔
215
                var lastProgress *db.BulkCensusParticipantStatus
3✔
216
                for p := range progressChan {
9✔
217
                        lastProgress = p
6✔
218
                        // Just drain the channel until it's closed
6✔
219
                        log.Debugw("census add participants",
6✔
220
                                "census", censusID.String(),
6✔
221
                                "org", census.OrgAddress,
6✔
222
                                "progress", p.Progress,
6✔
223
                                "added", p.Added,
6✔
224
                                "total", p.Total)
6✔
225
                }
6✔
226
                // Return the number of participants added
227
                apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{Added: uint32(lastProgress.Added)})
3✔
228
                return
3✔
229
        }
230

231
        // if async create a new job identifier
232
        jobID := internal.HexBytes(util.RandomBytes(16))
1✔
233
        go func() {
2✔
234
                for p := range progressChan {
3✔
235
                        // We need to drain the channel to avoid blocking
2✔
236
                        addParticipantsToCensusWorkers.Store(jobID.String(), p)
2✔
237
                }
2✔
238
        }()
239

240
        apicommon.HTTPWriteJSON(w, &apicommon.AddMembersResponse{JobID: jobID})
1✔
241
}
242

243
// censusAddParticipantsJobStatusHandler godoc
244
//
245
//        @Summary                Check the progress of adding participants
246
//        @Description        Check the progress of a job to add participants to a census. Returns the progress of the job.
247
//        @Description        If the job is completed, the job is deleted after 60 seconds.
248
//        @Tags                        census
249
//        @Accept                        json
250
//        @Produce                json
251
//        @Param                        jobid        path                string        true        "Job ID"
252
//        @Success                200                {object}        db.BulkCensusParticipantStatus
253
//        @Failure                400                {object}        errors.Error        "Invalid job ID"
254
//        @Failure                404                {object}        errors.Error        "Job not found"
255
//        @Router                        /census/job/{jobid} [get]
256
func (*API) censusAddParticipantsJobStatusHandler(w http.ResponseWriter, r *http.Request) {
4✔
257
        jobID := internal.HexBytes{}
4✔
258
        if err := jobID.ParseString(chi.URLParam(r, "jobid")); err != nil {
4✔
259
                errors.ErrMalformedURLParam.Withf("invalid job ID").Write(w)
×
260
                return
×
261
        }
×
262

263
        if v, ok := addParticipantsToCensusWorkers.Load(jobID.String()); ok {
8✔
264
                p, ok := v.(*db.BulkCensusParticipantStatus)
4✔
265
                if !ok {
4✔
266
                        errors.ErrGenericInternalServerError.Withf("invalid job status type").Write(w)
×
267
                        return
×
268
                }
×
269
                if p.Progress == 100 {
5✔
270
                        go func() {
2✔
271
                                // Schedule the deletion of the job after 60 seconds
1✔
272
                                time.Sleep(60 * time.Second)
1✔
273
                                addParticipantsToCensusWorkers.Delete(jobID.String())
1✔
274
                        }()
1✔
275
                }
276
                apicommon.HTTPWriteJSON(w, p)
4✔
277
                return
4✔
278
        }
279

280
        errors.ErrJobNotFound.Withf("%s", jobID.String()).Write(w)
×
281
}
282

283
// publishCensusHandler godoc
284
//
285
//        @Summary                Publish a census for voting
286
//        @Description        Publish a census for voting. Requires Manager/Admin role. Returns published census with credentials.
287
//        @Tags                        census
288
//        @Accept                        json
289
//        @Produce                json
290
//        @Security                BearerAuth
291
//        @Param                        id        path                string        true        "Census ID"
292
//        @Success                200        {object}        apicommon.PublishedCensusResponse
293
//        @Failure                400        {object}        errors.Error        "Invalid census ID"
294
//        @Failure                401        {object}        errors.Error        "Unauthorized"
295
//        @Failure                404        {object}        errors.Error        "Census not found"
296
//        @Failure                500        {object}        errors.Error        "Internal server error"
297
//        @Router                        /census/{id}/publish [post]
298
func (a *API) publishCensusHandler(w http.ResponseWriter, r *http.Request) {
4✔
299
        censusID := internal.HexBytes{}
4✔
300
        if err := censusID.ParseString(chi.URLParam(r, "id")); err != nil {
5✔
301
                errors.ErrMalformedURLParam.Withf("wrong census ID").Write(w)
1✔
302
                return
1✔
303
        }
1✔
304

305
        // get the user from the request context
306
        user, ok := apicommon.UserFromContext(r.Context())
3✔
307
        if !ok {
3✔
308
                errors.ErrUnauthorized.Write(w)
×
309
                return
×
310
        }
×
311

312
        // retrieve census
313
        census, err := a.db.Census(censusID.String())
3✔
314
        if err != nil {
3✔
315
                errors.ErrCensusNotFound.Write(w)
×
316
                return
×
317
        }
×
318

319
        // check the user has the necessary permissions
320
        if !user.HasRoleFor(census.OrgAddress, db.ManagerRole) && !user.HasRoleFor(census.OrgAddress, db.AdminRole) {
3✔
NEW
321
                errors.ErrUnauthorized.Withf("user does not have the necessary permissions in the organization").Write(w)
×
NEW
322
                return
×
NEW
323
        }
×
324

325
        if len(census.Published.Root) > 0 {
3✔
NEW
326
                // if the census is already published, return the censusInfo
×
NEW
327
                apicommon.HTTPWriteJSON(w, &apicommon.PublishedCensusResponse{
×
NEW
328
                        URI:  census.Published.URI,
×
NEW
329
                        Root: census.Published.Root,
×
NEW
330
                })
×
331
                return
×
332
        }
×
333

334
        // if census.Type == CensusTypeSMSOrMail || census.Type == CenT {
335
        // build the census and store it
336
        cspSignerPubKey := a.account.PubKey // TODO: use a different key based on the censusID
3✔
337
        switch census.Type {
3✔
338
        case CensusTypeSMSOrMail:
3✔
339
                census.Published.Root = cspSignerPubKey
3✔
340
                census.Published.URI = a.serverURL + "/process"
3✔
341
                census.Published.CreatedAt = time.Now()
3✔
342

343
        default:
×
344
                errors.ErrCensusTypeNotFound.Write(w)
×
345
                return
×
346
        }
347

348
        if _, err := a.db.SetCensus(census); err != nil {
3✔
349
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
350
                return
×
351
        }
×
352

353
        apicommon.HTTPWriteJSON(w, &apicommon.PublishedCensusResponse{
3✔
354
                URI:  census.Published.URI,
3✔
355
                Root: cspSignerPubKey,
3✔
356
        })
3✔
357
}
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