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

vocdoni / saas-backend / 29264688269

13 Jul 2026 04:01PM UTC coverage: 62.143% (-0.8%) from 62.898%
29264688269

Pull #571

github

lucasmenendez
feat(processes): require a type or ballotProtocol per question at authoring

buildQuestions now rejects (400) a question that has neither a named type nor a raw
ballotProtocol override, and an unsupported named type. typeSetup is required for every named
type except singlechoice (multichoice keeps its maxChoices bounds); if both a type and a
ballotProtocol are given the ballotProtocol wins, matching VoteTypeFromQuestion. Enforced at
create/update so bad drafts fail fast, in addition to the existing publish-time guard.
Pull Request #571: feat: multi-question /processes API

1156 of 2045 new or added lines in 21 files covered. (56.53%)

6 existing lines in 3 files now uncovered.

11456 of 18435 relevant lines covered (62.14%)

44.43 hits per line

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

60.4
/api/processes.go
1
package api
2

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

9
        "github.com/ethereum/go-ethereum/common"
10
        "github.com/go-chi/chi/v5"
11
        "github.com/vocdoni/saas-backend/api/apicommon"
12
        "github.com/vocdoni/saas-backend/db"
13
        "github.com/vocdoni/saas-backend/errors"
14
        "go.mongodb.org/mongo-driver/bson/primitive"
15
)
16

17
// maxQuestionsPerProcess bounds the number of questions of a voting process (the node
18
// batch endpoint caps a batch at 100 transactions).
19
const maxQuestionsPerProcess = 100
20

21
// parseProcessDates parses the optional RFC3339 start/end dates of a create/update request.
22
func parseProcessDates(req *apicommon.CreateVotingProcessRequest) (start, end time.Time, err error) {
22✔
23
        if req.StartDate != "" {
40✔
24
                if start, err = time.Parse(time.RFC3339, req.StartDate); err != nil {
18✔
NEW
25
                        return start, end, fmt.Errorf("invalid startDate: %w", err)
×
NEW
26
                }
×
27
        }
28
        if req.EndDate != "" {
44✔
29
                if end, err = time.Parse(time.RFC3339, req.EndDate); err != nil {
22✔
NEW
30
                        return start, end, fmt.Errorf("invalid endDate: %w", err)
×
NEW
31
                }
×
32
        }
33
        return start, end, nil
22✔
34
}
35

36
// createVotingProcessHandler godoc
37
//
38
//        @Summary                Create a voting process draft
39
//        @Description        Create a multi-question voting process draft. Requires Manager/Admin role of the org
40
//        @Description        (or a scoped API key with `voting:write`). Creates the inline census unpublished.
41
//        @Description        Each question must define either a named `type` (with `typeSetup` for multichoice)
42
//        @Description        or a raw `ballotProtocol` override; if both are given the `ballotProtocol` wins.
43
//        @Tags                        processes
44
//        @Accept                        json
45
//        @Produce                json
46
//        @Security                BearerAuth
47
//        @Param                        request        body                apicommon.CreateVotingProcessRequest        true        "Voting process"
48
//        @Success                200                {object}        apicommon.CreateVotingProcessResponse
49
//        @Failure                400                {object}        errors.Error
50
//        @Failure                401                {object}        errors.Error
51
//        @Failure                403                {object}        errors.Error
52
//        @Router                        /processes [post]
53
func (a *API) createVotingProcessHandler(w http.ResponseWriter, r *http.Request) {
21✔
54
        req := &apicommon.CreateVotingProcessRequest{}
21✔
55
        if err := json.NewDecoder(r.Body).Decode(req); err != nil {
21✔
NEW
56
                errors.ErrMalformedBody.Write(w)
×
NEW
57
                return
×
NEW
58
        }
×
59
        user, ok := apicommon.UserFromContext(r.Context())
21✔
60
        if !ok {
21✔
NEW
61
                errors.ErrUnauthorized.Write(w)
×
NEW
62
                return
×
NEW
63
        }
×
64
        if req.OrgAddress == (common.Address{}) {
21✔
NEW
65
                errors.ErrMalformedBody.Withf("missing org address").Write(w)
×
NEW
66
                return
×
NEW
67
        }
×
68
        if !user.HasRoleFor(req.OrgAddress, db.ManagerRole) && !user.HasRoleFor(req.OrgAddress, db.AdminRole) {
22✔
69
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization").Write(w)
1✔
70
                return
1✔
71
        }
1✔
72
        if len(req.Questions) == 0 || len(req.Questions) > maxQuestionsPerProcess {
21✔
73
                errors.ErrMalformedBody.Withf("questions must be between 1 and %d", maxQuestionsPerProcess).Write(w)
1✔
74
                return
1✔
75
        }
1✔
76
        if err := a.subscriptions.OrgCanCreateVotingProcessDraft(req.OrgAddress); err != nil {
19✔
NEW
77
                writeSubscriptionError(w, err)
×
NEW
78
                return
×
NEW
79
        }
×
80
        start, end, err := parseProcessDates(req)
19✔
81
        if err != nil {
19✔
NEW
82
                errors.ErrMalformedBody.WithErr(err).Write(w)
×
NEW
83
                return
×
NEW
84
        }
×
85
        census, err := a.resolveOrCreateDefaultCensus(req.Census, req.OrgAddress)
19✔
86
        if err != nil {
19✔
NEW
87
                writeSubscriptionError(w, err)
×
NEW
88
                return
×
NEW
89
        }
×
90
        // validate + build the questions (incl. eligibility against the census) before any process
91
        // write, so a bad request rolls the census back and never creates a half-written draft.
92
        built, err := a.buildQuestions(req.OrgAddress, req.Questions, census)
19✔
93
        if err != nil {
24✔
94
                _ = a.db.DelCensus(census.ID.Hex())
5✔
95
                writeSubscriptionError(w, err)
5✔
96
                return
5✔
97
        }
5✔
98

99
        vp := &db.VotingProcess{
14✔
100
                OrgAddress:  req.OrgAddress,
14✔
101
                Published:   false,
14✔
102
                Title:       req.Title,
14✔
103
                Description: req.Description,
14✔
104
                Header:      req.Header,
14✔
105
                StreamURI:   req.StreamURI,
14✔
106
                StartDate:   start,
14✔
107
                EndDate:     end,
14✔
108
                CensusID:    census.ID,
14✔
109
        }
14✔
110
        vpID, err := a.db.SetVotingProcess(vp)
14✔
111
        if err != nil {
14✔
NEW
112
                _ = a.db.DelCensus(census.ID.Hex())
×
NEW
113
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
114
                return
×
NEW
115
        }
×
116
        if err := a.writeQuestions(vp, built); err != nil {
14✔
NEW
117
                // roll back the just-created draft and its census so a failed create leaves nothing
×
NEW
118
                // behind (an orphaned draft would still count against the org's MaxDrafts quota).
×
NEW
119
                _ = a.db.DeleteVotingProcess(vpID)
×
NEW
120
                _ = a.db.DelCensus(census.ID.Hex())
×
NEW
121
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
122
                return
×
NEW
123
        }
×
124
        apicommon.HTTPWriteJSON(w, apicommon.CreateVotingProcessResponse{ProcessID: vpID.Hex()})
14✔
125
}
126

127
// buildQuestions resolves and validates the questions of a voting process in memory — including
128
// each question's eligibility subset against the census — WITHOUT writing anything, so a caller
129
// can validate before mutating the draft. ProcessID is assigned later by writeQuestions.
130
func (a *API) buildQuestions(
131
        orgAddress common.Address, questions []apicommon.VotingProcessQuestionRequest, census *db.Census,
132
) ([]*db.VotingProcessQuestion, error) {
22✔
133
        built := make([]*db.VotingProcessQuestion, 0, len(questions))
22✔
134
        for i, q := range questions {
62✔
135
                // ballot shape: a question must define EITHER a named type OR a raw BallotProtocol
40✔
136
                // override; if both are set the BallotProtocol wins (VoteTypeFromQuestion uses it). For a
40✔
137
                // named type, typeSetup is required for every type except singlechoice (which ignores it
40✔
138
                // on chain); a multichoice maps MaxChoices onto MaxTotalCost so it must be bounded.
40✔
139
                if q.BallotProtocol == nil {
79✔
140
                        switch q.Type {
39✔
141
                        case "":
1✔
142
                                return nil, errors.ErrInvalidData.Withf("question %d: a type or a ballotProtocol is required", i)
1✔
143
                        case db.VotingTypeSingleChoice:
19✔
144
                                // singlechoice ignores typeSetup
145
                        case db.VotingTypeMultiChoice:
18✔
146
                                if q.TypeSetup.MaxChoices < 1 || q.TypeSetup.MaxChoices > uint32(len(q.Choices)) {
20✔
147
                                        return nil, errors.ErrInvalidData.Withf(
2✔
148
                                                "question %d: maxChoices must be between 1 and the number of choices (%d)", i, len(q.Choices))
2✔
149
                                }
2✔
150
                                if q.TypeSetup.MinChoices > q.TypeSetup.MaxChoices {
16✔
NEW
151
                                        return nil, errors.ErrInvalidData.Withf("question %d: minChoices cannot exceed maxChoices", i)
×
NEW
152
                                }
×
153
                        default:
1✔
154
                                return nil, errors.ErrInvalidData.Withf("question %d: unsupported type %q", i, q.Type)
1✔
155
                        }
156
                }
157
                eligible, err := a.resolveEligibleMemberIDs(q.Eligibility, census, orgAddress)
36✔
158
                if err != nil {
37✔
159
                        return nil, err
1✔
160
                }
1✔
161
                built = append(built, &db.VotingProcessQuestion{
35✔
162
                        OrgAddress:        orgAddress,
35✔
163
                        Order:             i,
35✔
164
                        Title:             q.Title,
35✔
165
                        Description:       q.Description,
35✔
166
                        Choices:           q.Choices,
35✔
167
                        Type:              q.Type,
35✔
168
                        TypeSetup:         q.TypeSetup,
35✔
169
                        BallotProtocol:    q.BallotProtocol,
35✔
170
                        SecretUntilTheEnd: q.SecretUntilTheEnd,
35✔
171
                        EligibleMemberIDs: eligible,
35✔
172
                        Metadata:          q.Metadata,
35✔
173
                })
35✔
174
        }
175
        return built, nil
17✔
176
}
177

178
// writeQuestions replaces the process's stored questions with a pre-built (already validated)
179
// set and updates its ordered QuestionIDs. Existing questions are removed first so a draft
180
// update replaces them. Callers run buildQuestions first, so this only fails on infra errors.
181
func (a *API) writeQuestions(vp *db.VotingProcess, built []*db.VotingProcessQuestion) error {
17✔
182
        existing, err := a.db.QuestionsByProcess(vp.ID)
17✔
183
        if err != nil {
17✔
NEW
184
                return fmt.Errorf("failed to load existing questions: %w", err)
×
NEW
185
        }
×
186
        for i := range existing {
23✔
187
                if err := a.db.DeleteQuestion(existing[i].ID); err != nil {
6✔
NEW
188
                        return fmt.Errorf("failed to remove existing question: %w", err)
×
NEW
189
                }
×
190
        }
191
        questionIDs := make([]primitive.ObjectID, 0, len(built))
17✔
192
        for _, question := range built {
49✔
193
                question.ProcessID = vp.ID
32✔
194
                qID, err := a.db.SetQuestion(question)
32✔
195
                if err != nil {
32✔
NEW
196
                        return fmt.Errorf("failed to store question: %w", err)
×
NEW
197
                }
×
198
                questionIDs = append(questionIDs, qID)
32✔
199
        }
200
        vp.QuestionIDs = questionIDs
17✔
201
        if _, err := a.db.SetVotingProcess(vp); err != nil {
17✔
NEW
202
                return fmt.Errorf("failed to update process questions: %w", err)
×
NEW
203
        }
×
204
        return nil
17✔
205
}
206

207
// updateVotingProcessHandler godoc
208
//
209
//        @Summary                Update a voting process draft
210
//        @Description        Update a voting process while it is still a draft (not published). 409 if already published.
211
//        @Tags                        processes
212
//        @Accept                        json
213
//        @Produce                json
214
//        @Security                BearerAuth
215
//        @Param                        processId        path                string                                                                        true        "Process ID"
216
//        @Param                        request                body                apicommon.CreateVotingProcessRequest        true        "Voting process"
217
//        @Success                200                        {string}        string                                                                        "OK"
218
//        @Failure                400                        {object}        errors.Error
219
//        @Failure                401                        {object}        errors.Error
220
//        @Failure                404                        {object}        errors.Error
221
//        @Failure                409                        {object}        errors.Error
222
//        @Router                        /processes/{processId} [put]
223
func (a *API) updateVotingProcessHandler(w http.ResponseWriter, r *http.Request) {
3✔
224
        oid, ok := a.votingProcessID(w, r)
3✔
225
        if !ok {
3✔
NEW
226
                return
×
NEW
227
        }
×
228
        req := &apicommon.CreateVotingProcessRequest{}
3✔
229
        if err := json.NewDecoder(r.Body).Decode(req); err != nil {
3✔
NEW
230
                errors.ErrMalformedBody.Write(w)
×
NEW
231
                return
×
NEW
232
        }
×
233
        user, ok := apicommon.UserFromContext(r.Context())
3✔
234
        if !ok {
3✔
NEW
235
                errors.ErrUnauthorized.Write(w)
×
NEW
236
                return
×
NEW
237
        }
×
238
        vp, ok := a.loadVotingProcess(w, oid)
3✔
239
        if !ok {
3✔
NEW
240
                return
×
NEW
241
        }
×
242
        if vp.Published {
3✔
NEW
243
                errors.ErrDuplicateConflict.Withf("process already published and not in draft mode").Write(w)
×
NEW
244
                return
×
NEW
245
        }
×
246
        if !user.HasRoleFor(vp.OrgAddress, db.ManagerRole) && !user.HasRoleFor(vp.OrgAddress, db.AdminRole) {
3✔
NEW
247
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization").Write(w)
×
NEW
248
                return
×
NEW
249
        }
×
250
        if len(req.Questions) == 0 || len(req.Questions) > maxQuestionsPerProcess {
3✔
NEW
251
                errors.ErrMalformedBody.Withf("questions must be between 1 and %d", maxQuestionsPerProcess).Write(w)
×
NEW
252
                return
×
NEW
253
        }
×
254
        start, end, err := parseProcessDates(req)
3✔
255
        if err != nil {
3✔
NEW
256
                errors.ErrMalformedBody.WithErr(err).Write(w)
×
NEW
257
                return
×
NEW
258
        }
×
259
        // a draft update re-resolves the census into a fresh unpublished db.Census; the previous
260
        // one is reaped only after the update fully succeeds, so a failed edit neither orphans the
261
        // new census nor destroys the old draft.
262
        oldCensusID := vp.CensusID
3✔
263
        census, err := a.resolveOrCreateDefaultCensus(req.Census, vp.OrgAddress)
3✔
264
        if err != nil {
3✔
NEW
265
                writeSubscriptionError(w, err)
×
NEW
266
                return
×
NEW
267
        }
×
268
        // validate + build the new questions against the new census before any destructive write.
269
        built, err := a.buildQuestions(vp.OrgAddress, req.Questions, census)
3✔
270
        if err != nil {
3✔
NEW
271
                _ = a.db.DelCensus(census.ID.Hex())
×
NEW
272
                writeSubscriptionError(w, err)
×
NEW
273
                return
×
NEW
274
        }
×
275
        vp.Title, vp.Description, vp.Header, vp.StreamURI = req.Title, req.Description, req.Header, req.StreamURI
3✔
276
        vp.StartDate, vp.EndDate, vp.CensusID = start, end, census.ID
3✔
277
        if _, err := a.db.SetVotingProcess(vp); err != nil {
3✔
NEW
278
                _ = a.db.DelCensus(census.ID.Hex())
×
NEW
279
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
280
                return
×
NEW
281
        }
×
282
        if err := a.writeQuestions(vp, built); err != nil {
3✔
NEW
283
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
284
                return
×
NEW
285
        }
×
286
        // success: reap the previous census (and its participants) so edits don't accumulate orphans.
287
        if oldCensusID != census.ID {
6✔
288
                _ = a.db.DelCensus(oldCensusID.Hex())
3✔
289
        }
3✔
290
        apicommon.HTTPWriteOK(w)
3✔
291
}
292

293
// votingProcessInfoHandler godoc
294
//
295
//        @Summary                Get a voting process
296
//        @Description        Read a voting process with its fully hydrated questions (protected read).
297
//        @Tags                        processes
298
//        @Produce                json
299
//        @Security                BearerAuth
300
//        @Param                        processId        path                string        true        "Process ID"
301
//        @Success                200                        {object}        apicommon.VotingProcessResponse
302
//        @Failure                401                        {object}        errors.Error
303
//        @Failure                404                        {object}        errors.Error
304
//        @Router                        /processes/{processId} [get]
305
func (a *API) votingProcessInfoHandler(w http.ResponseWriter, r *http.Request) {
5✔
306
        oid, ok := a.votingProcessID(w, r)
5✔
307
        if !ok {
5✔
NEW
308
                return
×
NEW
309
        }
×
310
        user, ok := apicommon.UserFromContext(r.Context())
5✔
311
        if !ok {
5✔
NEW
312
                errors.ErrUnauthorized.Write(w)
×
NEW
313
                return
×
NEW
314
        }
×
315
        vp, questions, err := a.db.ProcessWithQuestions(oid)
5✔
316
        if err != nil {
5✔
NEW
317
                if err == db.ErrNotFound {
×
NEW
318
                        errors.ErrProcessNotFound.Write(w)
×
NEW
319
                        return
×
NEW
320
                }
×
NEW
321
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
322
                return
×
323
        }
324
        if !user.HasRoleFor(vp.OrgAddress, db.ManagerRole) && !user.HasRoleFor(vp.OrgAddress, db.AdminRole) {
5✔
NEW
325
                errors.ErrUnauthorized.Write(w)
×
NEW
326
                return
×
NEW
327
        }
×
328
        census, _ := a.db.Census(vp.CensusID.Hex())
5✔
329
        apicommon.HTTPWriteJSON(w, apicommon.VotingProcessResponseFromDB(vp, questions, census))
5✔
330
}
331

332
// listVotingProcessesHandler godoc
333
//
334
//        @Summary                List voting processes
335
//        @Description        Paginated list of an organization's voting processes (protected). Filter by question status.
336
//        @Tags                        processes
337
//        @Produce                json
338
//        @Security                BearerAuth
339
//        @Param                        orgAddress        query                string        true        "Organization address"
340
//        @Param                        status                query                string        false        "Filter by question status"
341
//        @Param                        page                query                int                false        "Page (1-based)"
342
//        @Param                        limit                query                int                false        "Page size"
343
//        @Success                200                        {object}        apicommon.VotingProcessListResponse
344
//        @Failure                401                        {object}        errors.Error
345
//        @Router                        /processes [get]
346
func (a *API) listVotingProcessesHandler(w http.ResponseWriter, r *http.Request) {
1✔
347
        user, ok := apicommon.UserFromContext(r.Context())
1✔
348
        if !ok {
1✔
NEW
349
                errors.ErrUnauthorized.Write(w)
×
NEW
350
                return
×
NEW
351
        }
×
352
        orgAddressStr := r.URL.Query().Get("orgAddress")
1✔
353
        if orgAddressStr == "" {
1✔
NEW
354
                errors.ErrMalformedURLParam.Withf("missing orgAddress").Write(w)
×
NEW
355
                return
×
NEW
356
        }
×
357
        if !common.IsHexAddress(orgAddressStr) {
1✔
NEW
358
                errors.ErrMalformedURLParam.Withf("invalid orgAddress").Write(w)
×
NEW
359
                return
×
NEW
360
        }
×
361
        orgAddress := common.HexToAddress(orgAddressStr)
1✔
362
        if !user.HasRoleFor(orgAddress, db.ManagerRole) && !user.HasRoleFor(orgAddress, db.AdminRole) {
1✔
NEW
363
                errors.ErrUnauthorized.Write(w)
×
NEW
364
                return
×
NEW
365
        }
×
366
        params, err := parsePaginationParams(r.URL.Query().Get(ParamPage), r.URL.Query().Get(ParamLimit))
1✔
367
        if err != nil {
1✔
NEW
368
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
NEW
369
                return
×
NEW
370
        }
×
371
        total, list, err := a.db.ListVotingProcesses(orgAddress, r.URL.Query().Get("status"), params.Page, params.Limit)
1✔
372
        if err != nil {
1✔
NEW
373
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
374
                return
×
NEW
375
        }
×
376
        pagination, err := calculatePagination(params.Page, params.Limit, total)
1✔
377
        if err != nil {
1✔
NEW
378
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
NEW
379
                return
×
NEW
380
        }
×
381
        resp := &apicommon.VotingProcessListResponse{
1✔
382
                Processes:  make([]apicommon.VotingProcessResponse, 0, len(list)),
1✔
383
                Pagination: pagination,
1✔
384
        }
1✔
385
        for i := range list {
2✔
386
                vp := &list[i]
1✔
387
                questions, err := a.db.QuestionsByProcess(vp.ID)
1✔
388
                if err != nil {
1✔
NEW
389
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
390
                        return
×
NEW
391
                }
×
392
                census, _ := a.db.Census(vp.CensusID.Hex())
1✔
393
                resp.Processes = append(resp.Processes, *apicommon.VotingProcessResponseFromDB(vp, questions, census))
1✔
394
        }
395
        apicommon.HTTPWriteJSON(w, resp)
1✔
396
}
397

398
// validateVotingProcessHandler godoc
399
//
400
//        @Summary                Validate a voting process for publishing
401
//        @Description        Publish-readiness dry-run. Returns { valid, errors } without changing anything.
402
//        @Tags                        processes
403
//        @Produce                json
404
//        @Security                BearerAuth
405
//        @Param                        processId        path                string        true        "Process ID"
406
//        @Success                200                        {object}        apicommon.VotingProcessValidateResponse
407
//        @Failure                401                        {object}        errors.Error
408
//        @Failure                404                        {object}        errors.Error
409
//        @Router                        /processes/{processId}/check [get]
410
func (a *API) validateVotingProcessHandler(w http.ResponseWriter, r *http.Request) {
3✔
411
        oid, ok := a.votingProcessID(w, r)
3✔
412
        if !ok {
3✔
NEW
413
                return
×
NEW
414
        }
×
415
        user, ok := apicommon.UserFromContext(r.Context())
3✔
416
        if !ok {
3✔
NEW
417
                errors.ErrUnauthorized.Write(w)
×
NEW
418
                return
×
NEW
419
        }
×
420
        vp, questions, err := a.db.ProcessWithQuestions(oid)
3✔
421
        if err != nil {
3✔
NEW
422
                if err == db.ErrNotFound {
×
NEW
423
                        errors.ErrProcessNotFound.Write(w)
×
NEW
424
                        return
×
NEW
425
                }
×
NEW
426
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
427
                return
×
428
        }
429
        if !user.HasRoleFor(vp.OrgAddress, db.ManagerRole) && !user.HasRoleFor(vp.OrgAddress, db.AdminRole) {
3✔
NEW
430
                errors.ErrUnauthorized.Write(w)
×
NEW
431
                return
×
NEW
432
        }
×
433
        census, _ := a.db.Census(vp.CensusID.Hex())
3✔
434
        problems := a.publishPreflightProblems(vp, questions, census, user)
3✔
435
        apicommon.HTTPWriteJSON(w, &apicommon.VotingProcessValidateResponse{
3✔
436
                Valid:  len(problems) == 0,
3✔
437
                Errors: problems,
3✔
438
        })
3✔
439
}
440

441
// votingProcessQuestionHandler godoc
442
//
443
//        @Summary                Get a voting process question
444
//        @Description        Public voter read of a single question, including its synced status and eligibility.
445
//        @Tags                        processes
446
//        @Produce                json
447
//        @Param                        processId        path                string        true        "Process ID"
448
//        @Param                        questionId        path                string        true        "Question ID"
449
//        @Success                200                        {object}        apicommon.PublicQuestionResponse
450
//        @Failure                404                        {object}        errors.Error
451
//        @Router                        /processes/{processId}/questions/{questionId} [get]
452
func (a *API) votingProcessQuestionHandler(w http.ResponseWriter, r *http.Request) {
3✔
453
        oid, ok := a.votingProcessID(w, r)
3✔
454
        if !ok {
3✔
NEW
455
                return
×
NEW
456
        }
×
457
        qid, err := primitive.ObjectIDFromHex(chi.URLParam(r, "questionId"))
3✔
458
        if err != nil {
3✔
NEW
459
                errors.ErrMalformedURLParam.Withf("invalid question ID").Write(w)
×
NEW
460
                return
×
NEW
461
        }
×
462
        question, err := a.db.Question(qid)
3✔
463
        if err != nil && err != db.ErrNotFound {
3✔
NEW
464
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
465
                return
×
NEW
466
        }
×
467
        if err != nil || question.ProcessID != oid {
3✔
NEW
468
                errors.ErrProcessNotFound.Withf("question not found").Write(w)
×
NEW
469
                return
×
NEW
470
        }
×
471
        // hydrate the parent process's census config (the auth policy the voter must satisfy); the
472
        // member list and per-question eligibility subset are never exposed on this public endpoint.
473
        vp, err := a.db.VotingProcess(oid)
3✔
474
        if err != nil {
3✔
NEW
475
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
476
                return
×
NEW
477
        }
×
478
        // this is a public (voter-facing) read: only published processes are visible, so drafts are
479
        // not readable by unauthenticated callers.
480
        if !vp.Published {
4✔
481
                errors.ErrProcessNotFound.Withf("question not found").Write(w)
1✔
482
                return
1✔
483
        }
1✔
484
        census, _ := a.db.Census(vp.CensusID.Hex())
2✔
485
        apicommon.HTTPWriteJSON(w, apicommon.PublicQuestionResponseFromDB(question, census))
2✔
486
}
487

488
// votingProcessParticipantHandler godoc
489
//
490
//        @Summary                Get a voting process participant
491
//        @Description        Public participant info for a voting process, mirroring the bundle participant
492
//        @Description        endpoint. Validates the process and participant id.
493
//        @Tags                        processes
494
//        @Produce                json
495
//        @Param                        processId                path                string        true        "Process ID"
496
//        @Param                        participantId        path                string        true        "Participant ID"
497
//        @Success                200                                {object}        interface{}
498
//        @Failure                400                                {object}        errors.Error
499
//        @Failure                404                                {object}        errors.Error
500
//        @Router                        /processes/{processId}/participant/{participantId} [get]
501
func (a *API) votingProcessParticipantHandler(w http.ResponseWriter, r *http.Request) {
3✔
502
        oid, ok := a.votingProcessID(w, r)
3✔
503
        if !ok {
4✔
504
                return
1✔
505
        }
1✔
506
        participantID := chi.URLParam(r, "participantId")
2✔
507
        if participantID == "" {
2✔
NEW
508
                errors.ErrMalformedURLParam.Withf("missing participant ID").Write(w)
×
NEW
509
                return
×
NEW
510
        }
×
511
        if _, ok := a.loadVotingProcess(w, oid); !ok {
3✔
512
                return
1✔
513
        }
1✔
514
        // mirrors processBundleParticipantInfoHandler: participant election info is not yet surfaced
515
        // (the bundle equivalent returns nil pending the CSP indexer lookup).
516
        apicommon.HTTPWriteJSON(w, nil)
1✔
517
}
518

519
// votingProcessResultsHandler godoc
520
//
521
//        @Summary                Get a voting process results
522
//        @Description        Public per-question on-chain results of a published voting process: one entry per
523
//        @Description        published question, each with the trimmed election state (status, vote count,
524
//        @Description        dates, whether final, and the tally). No authentication is required.
525
//        @Tags                        processes
526
//        @Produce                json
527
//        @Param                        processId        path                string        true        "Process ID"
528
//        @Success                200                        {object}        apicommon.VotingProcessResultsResponse
529
//        @Failure                400                        {object}        errors.Error
530
//        @Failure                404                        {object}        errors.Error
531
//        @Failure                500                        {object}        errors.Error
532
//        @Router                        /processes/{processId}/results [get]
533
func (a *API) votingProcessResultsHandler(w http.ResponseWriter, r *http.Request) {
2✔
534
        oid, ok := a.votingProcessID(w, r)
2✔
535
        if !ok {
2✔
NEW
536
                return
×
NEW
537
        }
×
538
        vp, questions, err := a.db.ProcessWithQuestions(oid)
2✔
539
        if err != nil {
2✔
NEW
540
                if err == db.ErrNotFound {
×
NEW
541
                        errors.ErrProcessNotFound.Write(w)
×
NEW
542
                        return
×
NEW
543
                }
×
NEW
544
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
545
                return
×
546
        }
547
        // results only exist once the process has been published on chain.
548
        if !vp.Published {
3✔
549
                errors.ErrProcessNotFound.Withf("process not published").Write(w)
1✔
550
                return
1✔
551
        }
1✔
552
        resp := &apicommon.VotingProcessResultsResponse{ID: oid.Hex()}
1✔
553
        for i := range questions {
3✔
554
                q := &questions[i]
2✔
555
                if len(q.UpstreamID) == 0 {
2✔
NEW
556
                        continue // question not yet on chain
×
557
                }
558
                election, err := a.account.Election(q.UpstreamID)
2✔
559
                if err != nil {
2✔
NEW
560
                        errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
NEW
561
                        return
×
NEW
562
                }
×
563
                entry := apicommon.VotingProcessQuestionResults{
2✔
564
                        QuestionID: q.ID.Hex(),
2✔
565
                        UpstreamID: q.UpstreamID,
2✔
566
                        ProcessResultsResponse: apicommon.ProcessResultsResponse{
2✔
567
                                Status:       election.Status,
2✔
568
                                VoteCount:    election.VoteCount,
2✔
569
                                StartDate:    election.StartDate,
2✔
570
                                EndDate:      election.EndDate,
2✔
571
                                FinalResults: election.FinalResults,
2✔
572
                        },
2✔
573
                }
2✔
574
                if len(election.Results) > 0 {
4✔
575
                        results := make([][]string, len(election.Results))
2✔
576
                        for j, question := range election.Results {
5✔
577
                                values := make([]string, len(question))
3✔
578
                                for k, value := range question {
9✔
579
                                        values[k] = value.String()
6✔
580
                                }
6✔
581
                                results[j] = values
3✔
582
                        }
583
                        entry.Results = results
2✔
584
                }
585
                resp.Questions = append(resp.Questions, entry)
2✔
586
        }
587
        apicommon.HTTPWriteJSON(w, resp)
1✔
588
}
589

590
// votingProcessID parses and validates the {processId} URL param.
591
func (*API) votingProcessID(w http.ResponseWriter, r *http.Request) (primitive.ObjectID, bool) {
26✔
592
        oid, err := primitive.ObjectIDFromHex(chi.URLParam(r, "processId"))
26✔
593
        if err != nil {
27✔
594
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
1✔
595
                return primitive.NilObjectID, false
1✔
596
        }
1✔
597
        return oid, true
25✔
598
}
599

600
// loadVotingProcess loads a voting process, writing the proper error on failure.
601
func (a *API) loadVotingProcess(w http.ResponseWriter, oid primitive.ObjectID) (*db.VotingProcess, bool) {
5✔
602
        vp, err := a.db.VotingProcess(oid)
5✔
603
        if err != nil {
6✔
604
                if err == db.ErrNotFound {
2✔
605
                        errors.ErrProcessNotFound.Write(w)
1✔
606
                        return nil, false
1✔
607
                }
1✔
NEW
608
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
609
                return nil, false
×
610
        }
611
        return vp, true
4✔
612
}
613

614
// validateVotingProcessForPublish returns the list of reasons a process cannot be published
615
// (empty when it is ready). Used by GET .../check and by publish.
616
func validateVotingProcessForPublish(
617
        vp *db.VotingProcess, questions []db.VotingProcessQuestion, census *db.Census,
618
) []string {
9✔
619
        var problems []string
9✔
620
        if len(vp.Title) == 0 {
9✔
NEW
621
                problems = append(problems, "missing title")
×
NEW
622
        }
×
623
        if vp.EndDate.IsZero() || !vp.EndDate.After(time.Now()) {
9✔
NEW
624
                problems = append(problems, "endDate must be in the future")
×
NEW
625
        }
×
626
        if !vp.StartDate.IsZero() && !vp.EndDate.After(vp.StartDate) {
9✔
NEW
627
                problems = append(problems, "endDate must be after startDate")
×
NEW
628
        }
×
629
        if census == nil {
9✔
NEW
630
                problems = append(problems, "census not resolvable")
×
NEW
631
        }
×
632
        if len(questions) == 0 {
9✔
NEW
633
                problems = append(problems, "at least one question is required")
×
NEW
634
        }
×
635
        for i := range questions {
25✔
636
                q := &questions[i]
16✔
637
                if len(q.Choices) == 0 {
16✔
NEW
638
                        problems = append(problems, fmt.Sprintf("question %d has no choices", i))
×
NEW
639
                }
×
640
                if q.BallotProtocol == nil && q.Type != db.VotingTypeSingleChoice && q.Type != db.VotingTypeMultiChoice {
16✔
NEW
641
                        problems = append(problems, fmt.Sprintf("question %d has an unsupported type %q", i, q.Type))
×
NEW
642
                }
×
643
        }
644
        return problems
9✔
645
}
646

647
// writeSubscriptionError writes a typed API error verbatim, falling back to 500.
648
func writeSubscriptionError(w http.ResponseWriter, err error) {
5✔
649
        if apiErr, ok := err.(errors.Error); ok {
10✔
650
                apiErr.Write(w)
5✔
651
                return
5✔
652
        }
5✔
NEW
653
        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
654
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc