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

vocdoni / saas-backend / 29813735250

21 Jul 2026 08:17AM UTC coverage: 62.421%. First build
29813735250

Pull #583

github

web-flow
chore(lint): fix all golangci-lint issues for v2.12 (#584)
Pull Request #583: feat: stage upgrade

1584 of 2681 new or added lines in 47 files covered. (59.08%)

11795 of 18896 relevant lines covered (62.42%)

45.45 hits per line

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

61.89
/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) {
25✔
23
        if req.StartDate != "" {
44✔
24
                if start, err = time.Parse(time.RFC3339, req.StartDate); err != nil {
19✔
NEW
25
                        return start, end, fmt.Errorf("invalid startDate: %w", err)
×
NEW
26
                }
×
27
        }
28
        if req.EndDate != "" {
50✔
29
                if end, err = time.Parse(time.RFC3339, req.EndDate); err != nil {
25✔
NEW
30
                        return start, end, fmt.Errorf("invalid endDate: %w", err)
×
NEW
31
                }
×
32
        }
33
        return start, end, nil
25✔
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) {
24✔
54
        req := &apicommon.CreateVotingProcessRequest{}
24✔
55
        if err := json.NewDecoder(r.Body).Decode(req); err != nil {
24✔
NEW
56
                errors.ErrMalformedBody.Write(w)
×
NEW
57
                return
×
NEW
58
        }
×
59
        user, ok := apicommon.UserFromContext(r.Context())
24✔
60
        if !ok {
24✔
NEW
61
                errors.ErrUnauthorized.Write(w)
×
NEW
62
                return
×
NEW
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. The
66
        // zero address is treated as missing (it can never own an organization).
67
        orgAddr := common.BytesToAddress(req.OrgAddress)
24✔
68
        if len(req.OrgAddress) != common.AddressLength || orgAddr == (common.Address{}) {
24✔
NEW
69
                errors.ErrMalformedBody.Withf("missing or invalid org address").Write(w)
×
NEW
70
                return
×
NEW
71
        }
×
72
        if !user.HasRoleFor(orgAddr, db.ManagerRole) && !user.HasRoleFor(orgAddr, db.AdminRole) {
25✔
73
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization").Write(w)
1✔
74
                return
1✔
75
        }
1✔
76
        if len(req.Questions) == 0 || len(req.Questions) > maxQuestionsPerProcess {
24✔
77
                errors.ErrMalformedBody.Withf("questions must be between 1 and %d", maxQuestionsPerProcess).Write(w)
1✔
78
                return
1✔
79
        }
1✔
80
        if err := a.subscriptions.OrgCanCreateVotingProcessDraft(orgAddr); err != nil {
22✔
NEW
81
                writeSubscriptionError(w, err)
×
NEW
82
                return
×
NEW
83
        }
×
84
        start, end, err := parseProcessDates(req)
22✔
85
        if err != nil {
22✔
NEW
86
                errors.ErrMalformedBody.WithErr(err).Write(w)
×
NEW
87
                return
×
NEW
88
        }
×
89
        census, err := a.resolveOrCreateDefaultCensus(req.Census, orgAddr)
22✔
90
        if err != nil {
22✔
NEW
91
                writeSubscriptionError(w, err)
×
NEW
92
                return
×
NEW
93
        }
×
94
        // validate + build the questions (incl. eligibility against the census) before any process
95
        // write, so a bad request rolls the census back and never creates a half-written draft.
96
        built, err := a.buildQuestions(orgAddr, req.Questions, census)
22✔
97
        if err != nil {
27✔
98
                _ = a.db.DelCensus(census.ID.Hex())
5✔
99
                writeSubscriptionError(w, err)
5✔
100
                return
5✔
101
        }
5✔
102

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

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

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

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

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

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

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

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

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

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

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

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

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

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