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

vocdoni / saas-backend / 29410417712

15 Jul 2026 11:05AM UTC coverage: 62.167% (+0.002%) from 62.165%
29410417712

Pull #579

github

lucasmenendez
fix(processes): serialize org address as internal.HexBytes (bare hex)

The /processes API typed orgAddress as common.Address, which JSON-marshals to a
0x-prefixed EIP-55-checksummed string — inconsistent with upstreamId (and the
other hex ids in these types), which use internal.HexBytes (bare lowercase hex).
Switch CreateVotingProcessRequest.OrgAddress and VotingProcessResponse.OrgAddress
to internal.HexBytes for a consistent API.

- Response: VotingProcessResponseFromDB converts the db common.Address via .Bytes().
- Request: createVotingProcessHandler validates len == common.AddressLength
  (HexBytes, unlike common.Address, doesn't enforce a 20-byte length on decode)
  and converts once to common.Address for the db/auth/subscription calls.
- Inputs stay compatible: HexBytes.UnmarshalJSON strips an optional 0x prefix, so
  clients may still send "0x…". Only the response format changes to bare hex.

Scope is limited to what #571 introduced; legacy /process and census types keep
common.Address.
Pull Request #579: fix(processes): serialize org address as internal.HexBytes (bare hex)

8 of 9 new or added lines in 2 files covered. (88.89%)

56 existing lines in 1 file now uncovered.

11494 of 18489 relevant lines covered (62.17%)

44.71 hits per line

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

60.93
/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 != "" {
39✔
24
                if start, err = time.Parse(time.RFC3339, req.StartDate); err != nil {
17✔
25
                        return start, end, fmt.Errorf("invalid startDate: %w", err)
×
26
                }
×
27
        }
28
        if req.EndDate != "" {
44✔
29
                if end, err = time.Parse(time.RFC3339, req.EndDate); err != nil {
22✔
30
                        return start, end, fmt.Errorf("invalid endDate: %w", err)
×
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✔
56
                errors.ErrMalformedBody.Write(w)
×
57
                return
×
58
        }
×
59
        user, ok := apicommon.UserFromContext(r.Context())
21✔
60
        if !ok {
21✔
61
                errors.ErrUnauthorized.Write(w)
×
62
                return
×
63
        }
×
64
        // orgAddress is internal.HexBytes over the API (bare-hex JSON, like upstreamId); unlike
65
        // common.Address it doesn't enforce a 20-byte length on decode, so validate it here.
66
        if len(req.OrgAddress) != common.AddressLength {
21✔
NEW
67
                errors.ErrMalformedBody.Withf("missing or invalid org address").Write(w)
×
68
                return
×
69
        }
×
70
        orgAddr := common.BytesToAddress(req.OrgAddress)
21✔
71
        if !user.HasRoleFor(orgAddr, db.ManagerRole) && !user.HasRoleFor(orgAddr, db.AdminRole) {
22✔
72
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization").Write(w)
1✔
73
                return
1✔
74
        }
1✔
75
        if len(req.Questions) == 0 || len(req.Questions) > maxQuestionsPerProcess {
21✔
76
                errors.ErrMalformedBody.Withf("questions must be between 1 and %d", maxQuestionsPerProcess).Write(w)
1✔
77
                return
1✔
78
        }
1✔
79
        if err := a.subscriptions.OrgCanCreateVotingProcessDraft(orgAddr); err != nil {
19✔
80
                writeSubscriptionError(w, err)
×
81
                return
×
82
        }
×
83
        start, end, err := parseProcessDates(req)
19✔
84
        if err != nil {
19✔
UNCOV
85
                errors.ErrMalformedBody.WithErr(err).Write(w)
×
86
                return
×
87
        }
×
88
        census, err := a.resolveOrCreateDefaultCensus(req.Census, orgAddr)
19✔
89
        if err != nil {
19✔
UNCOV
90
                writeSubscriptionError(w, err)
×
91
                return
×
92
        }
×
93
        // validate + build the questions (incl. eligibility against the census) before any process
94
        // write, so a bad request rolls the census back and never creates a half-written draft.
95
        built, err := a.buildQuestions(orgAddr, req.Questions, census)
19✔
96
        if err != nil {
24✔
97
                _ = a.db.DelCensus(census.ID.Hex())
5✔
98
                writeSubscriptionError(w, err)
5✔
99
                return
5✔
100
        }
5✔
101

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

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

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

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

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

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

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

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

491
// votingProcessParticipantHandler godoc
492
//
493
//        @Summary                Get a voting process participant
494
//        @Description        Public participant info for a published voting process, mirroring the bundle
495
//        @Description        participant endpoint. PLACEHOLDER: validates the process (published only) and the
496
//        @Description        participant id, and currently returns null — participant election info is not yet
497
//        @Description        surfaced (the bundle equivalent is likewise a stub pending the CSP indexer lookup).
498
//        @Tags                        processes
499
//        @Produce                json
500
//        @Param                        processId                path                string                true        "Process ID"
501
//        @Param                        participantId        path                string                true        "Participant ID"
502
//        @Success                200                                {object}        interface{}        "Placeholder: null until participant info is surfaced"
503
//        @Failure                400                                {object}        errors.Error
504
//        @Failure                404                                {object}        errors.Error
505
//        @Router                        /processes/{processId}/participant/{participantId} [get]
506
func (a *API) votingProcessParticipantHandler(w http.ResponseWriter, r *http.Request) {
4✔
507
        oid, ok := a.votingProcessID(w, r)
4✔
508
        if !ok {
5✔
509
                return
1✔
510
        }
1✔
511
        participantID := chi.URLParam(r, "participantId")
3✔
512
        if participantID == "" {
3✔
UNCOV
513
                errors.ErrMalformedURLParam.Withf("missing participant ID").Write(w)
×
514
                return
×
515
        }
×
516
        vp, ok := a.loadVotingProcess(w, oid)
3✔
517
        if !ok {
4✔
518
                return
1✔
519
        }
1✔
520
        // public (voter-facing) read: only published processes are visible, so a draft is not
521
        // revealed to unauthenticated callers.
522
        if !vp.Published {
3✔
523
                errors.ErrProcessNotFound.Withf("process not found").Write(w)
1✔
524
                return
1✔
525
        }
1✔
526
        // mirrors processBundleParticipantInfoHandler: participant election info is not yet surfaced
527
        // (the bundle equivalent returns nil pending the CSP indexer lookup).
528
        apicommon.HTTPWriteJSON(w, nil)
1✔
529
}
530

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

602
// votingProcessID parses and validates the {processId} URL param.
603
func (*API) votingProcessID(w http.ResponseWriter, r *http.Request) (primitive.ObjectID, bool) {
28✔
604
        oid, err := primitive.ObjectIDFromHex(chi.URLParam(r, "processId"))
28✔
605
        if err != nil {
29✔
606
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
1✔
607
                return primitive.NilObjectID, false
1✔
608
        }
1✔
609
        return oid, true
27✔
610
}
611

612
// loadVotingProcess loads a voting process, writing the proper error on failure.
613
func (a *API) loadVotingProcess(w http.ResponseWriter, oid primitive.ObjectID) (*db.VotingProcess, bool) {
6✔
614
        vp, err := a.db.VotingProcess(oid)
6✔
615
        if err != nil {
7✔
616
                if err == db.ErrNotFound {
2✔
617
                        errors.ErrProcessNotFound.Write(w)
1✔
618
                        return nil, false
1✔
619
                }
1✔
UNCOV
620
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
621
                return nil, false
×
622
        }
623
        return vp, true
5✔
624
}
625

626
// validateVotingProcessForPublish returns the list of reasons a process cannot be published
627
// (empty when it is ready). Used by GET .../check and by publish.
628
func validateVotingProcessForPublish(
629
        vp *db.VotingProcess, questions []db.VotingProcessQuestion, census *db.Census,
630
) []string {
10✔
631
        var problems []string
10✔
632
        if len(vp.Title) == 0 {
10✔
UNCOV
633
                problems = append(problems, "missing title")
×
634
        }
×
635
        if vp.EndDate.IsZero() || !vp.EndDate.After(time.Now()) {
10✔
UNCOV
636
                problems = append(problems, "endDate must be in the future")
×
637
        }
×
638
        if !vp.StartDate.IsZero() && !vp.EndDate.After(vp.StartDate) {
10✔
UNCOV
639
                problems = append(problems, "endDate must be after startDate")
×
640
        }
×
641
        if census == nil {
10✔
UNCOV
642
                problems = append(problems, "census not resolvable")
×
643
        }
×
644
        if len(questions) == 0 {
10✔
UNCOV
645
                problems = append(problems, "at least one question is required")
×
646
        }
×
647
        for i := range questions {
28✔
648
                q := &questions[i]
18✔
649
                if len(q.Choices) == 0 {
18✔
UNCOV
650
                        problems = append(problems, fmt.Sprintf("question %d has no choices", i))
×
651
                }
×
652
                if q.BallotProtocol == nil && q.Type != db.VotingTypeSingleChoice && q.Type != db.VotingTypeMultiChoice {
18✔
UNCOV
653
                        problems = append(problems, fmt.Sprintf("question %d has an unsupported type %q", i, q.Type))
×
654
                }
×
655
        }
656
        return problems
10✔
657
}
658

659
// writeSubscriptionError writes a typed API error verbatim, falling back to 500.
660
func writeSubscriptionError(w http.ResponseWriter, err error) {
5✔
661
        if apiErr, ok := err.(errors.Error); ok {
10✔
662
                apiErr.Write(w)
5✔
663
                return
5✔
664
        }
5✔
UNCOV
665
        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
666
}
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