• 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

52.72
/api/processes_admin.go
1
package api
2

3
import (
4
        "encoding/json"
5
        stderrors "errors"
6
        "net/http"
7

8
        "github.com/vocdoni/saas-backend/account"
9
        "github.com/vocdoni/saas-backend/api/apicommon"
10
        "github.com/vocdoni/saas-backend/db"
11
        "github.com/vocdoni/saas-backend/errors"
12
        "go.vocdoni.io/dvote/log"
13
)
14

15
// deleteVotingProcessHandler godoc
16
//
17
//        @Summary                Delete a voting process draft
18
//        @Description        Delete an unpublished voting process draft together with its inline census. A
19
//        @Description        published process has on-chain elections and cannot be deleted. Requires
20
//        @Description        Manager/Admin role of the organization that owns the process.
21
//        @Tags                        processes
22
//        @Produce                json
23
//        @Security                BearerAuth
24
//        @Param                        processId        path                string                        true        "Process ID"
25
//        @Success                200                        {string}        string                        "OK"
26
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
27
//        @Failure                404                        {object}        errors.Error        "Process not found"
28
//        @Failure                409                        {object}        errors.Error        "Process already published"
29
//        @Failure                500                        {object}        errors.Error        "Internal server error"
30
//        @Router                        /processes/{processId} [delete]
31
func (a *API) deleteVotingProcessHandler(w http.ResponseWriter, r *http.Request) {
3✔
32
        oid, ok := a.votingProcessID(w, r)
3✔
33
        if !ok {
3✔
NEW
34
                return
×
NEW
35
        }
×
36
        // loads the process + questions and gates on Manager/Admin of the owning org.
37
        vp, _, ok := a.authorizeStatusChange(w, r, oid)
3✔
38
        if !ok {
4✔
39
                return
1✔
40
        }
1✔
41
        // only a draft can be deleted; a published process lives on-chain and is immutable.
42
        if vp.Published {
3✔
43
                errors.ErrDuplicateConflict.Withf("process already published and not in draft mode").Write(w)
1✔
44
                return
1✔
45
        }
1✔
46
        if err := a.db.DeleteVotingProcess(oid); err != nil {
1✔
NEW
47
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
48
                return
×
NEW
49
        }
×
50
        // best-effort: drop the draft's inline census so it is not orphaned.
51
        if !vp.CensusID.IsZero() {
2✔
52
                _ = a.db.DelCensus(vp.CensusID.Hex())
1✔
53
        }
1✔
54
        apicommon.HTTPWriteOK(w)
1✔
55
}
56

57
// votingProcessParticipantsHandler godoc
58
//
59
//        @Summary                List voted participants of a voting process
60
//        @Description        Manager/Admin lookup of organization members by a single field (email, phone,
61
//        @Description        memberNumber, nationalId), intersected with the process census, reporting each
62
//        @Description        matched member's per-question voted status. For `phone` pass the plaintext number;
63
//        @Description        it is hashed server-side. Requires Manager/Admin of the owning organization.
64
//        @Tags                        processes
65
//        @Produce                json
66
//        @Security                BearerAuth
67
//        @Param                        processId        path                string        true        "Process ID"
68
//        @Param                        field                query                string        true        "Lookup field: email, phone, memberNumber or nationalId"
69
//        @Param                        value                query                string        true        "Value to match for the given field"
70
//        @Success                200                        {object}        apicommon.ProcessParticipantsResponse
71
//        @Failure                400                        {object}        errors.Error        "Invalid input data"
72
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
73
//        @Failure                404                        {object}        errors.Error        "Process not found"
74
//        @Failure                500                        {object}        errors.Error        "Internal server error"
75
//        @Router                        /processes/{processId}/participants [get]
76
func (a *API) votingProcessParticipantsHandler(w http.ResponseWriter, r *http.Request) {
1✔
77
        oid, ok := a.votingProcessID(w, r)
1✔
78
        if !ok {
1✔
NEW
79
                return
×
NEW
80
        }
×
81
        vp, questions, ok := a.authorizeStatusChange(w, r, oid)
1✔
82
        if !ok {
1✔
NEW
83
                return
×
NEW
84
        }
×
85
        field := db.OrgMemberLookupField(r.URL.Query().Get("field"))
1✔
86
        if !field.IsValid() {
1✔
NEW
87
                errors.ErrMalformedBody.Withf("invalid field: must be one of email, phone, memberNumber, nationalId").Write(w)
×
NEW
88
                return
×
NEW
89
        }
×
90
        value := r.URL.Query().Get("value")
1✔
91
        if value == "" {
1✔
NEW
92
                errors.ErrMalformedBody.Withf("missing value").Write(w)
×
NEW
93
                return
×
NEW
94
        }
×
95
        // phone is stored hashed, so hash the plaintext before looking up.
96
        var lookupValue any = value
1✔
97
        if field == db.OrgMemberLookupFieldPhone {
1✔
NEW
98
                org, err := a.db.Organization(vp.OrgAddress)
×
NEW
99
                if err != nil {
×
NEW
100
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
101
                        return
×
NEW
102
                }
×
NEW
103
                hashed, err := db.NewHashedPhone(value, org)
×
NEW
104
                if err != nil || hashed.IsEmpty() {
×
NEW
105
                        errors.ErrMalformedBody.Withf("invalid phone").Write(w)
×
NEW
106
                        return
×
NEW
107
                }
×
NEW
108
                lookupValue = hashed
×
109
        }
110

111
        resp := apicommon.ProcessParticipantsResponse{Participants: []apicommon.ProcessParticipantEntry{}}
1✔
112
        members, err := a.db.OrgMembersByField(vp.OrgAddress, field, lookupValue)
1✔
113
        if err != nil {
1✔
NEW
114
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
115
                return
×
NEW
116
        }
×
117
        if len(members) == 0 {
1✔
NEW
118
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
119
                return
×
NEW
120
        }
×
121
        memberIDs := make([]string, 0, len(members))
1✔
122
        membersByID := make(map[string]*db.OrgMember, len(members))
1✔
123
        for _, m := range members {
2✔
124
                id := m.ID.Hex()
1✔
125
                memberIDs = append(memberIDs, id)
1✔
126
                membersByID[id] = m
1✔
127
        }
1✔
128
        participants, err := a.db.CensusParticipantsByMemberIDs(vp.CensusID.Hex(), memberIDs)
1✔
129
        if err != nil {
1✔
NEW
130
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
131
                return
×
NEW
132
        }
×
133
        if len(participants) == 0 {
1✔
NEW
134
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
135
                return
×
NEW
136
        }
×
137
        participantIDs := make([]string, 0, len(participants))
1✔
138
        for _, p := range participants {
2✔
139
                participantIDs = append(participantIDs, p.ParticipantID)
1✔
140
        }
1✔
141
        // per-question voted status: each question is its own on-chain election, keyed by upstreamId.
142
        votedByQuestion := make(map[string]map[string]bool, len(questions))
1✔
143
        for i := range questions {
3✔
144
                q := &questions[i]
2✔
145
                if len(q.UpstreamID) == 0 {
2✔
NEW
146
                        continue // question not yet on chain
×
147
                }
148
                voted, err := a.db.MembersWithUsedCSPProcess(q.UpstreamID, participantIDs)
2✔
149
                if err != nil {
2✔
NEW
150
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
151
                        return
×
NEW
152
                }
×
153
                votedByQuestion[q.ID.Hex()] = voted
2✔
154
        }
155
        for _, p := range participants {
2✔
156
                m, exists := membersByID[p.ParticipantID]
1✔
157
                if !exists {
1✔
NEW
158
                        continue
×
159
                }
160
                entry := apicommon.ProcessParticipantEntry{
1✔
161
                        MemberID:     m.ID.Hex(),
1✔
162
                        Name:         m.Name,
1✔
163
                        Surname:      m.Surname,
1✔
164
                        Email:        m.Email,
1✔
165
                        MemberNumber: m.MemberNumber,
1✔
166
                }
1✔
167
                for i := range questions {
3✔
168
                        q := &questions[i]
2✔
169
                        if len(q.UpstreamID) == 0 {
2✔
NEW
170
                                continue
×
171
                        }
172
                        entry.Questions = append(entry.Questions, apicommon.ProcessParticipantQuestionVote{
2✔
173
                                QuestionID: q.ID.Hex(),
2✔
174
                                UpstreamID: q.UpstreamID,
2✔
175
                                HasVoted:   votedByQuestion[q.ID.Hex()][m.ID.Hex()],
2✔
176
                        })
2✔
177
                }
178
                resp.Participants = append(resp.Participants, entry)
1✔
179
        }
180
        apicommon.HTTPWriteJSON(w, resp)
1✔
181
}
182

183
// updateVotingProcessCensusHandler godoc
184
//
185
//        @Summary                Add members to a published process's census
186
//        @Description        Add existing organization members to the census of an already-published voting process
187
//        @Description        (same behaviour as POST /census/{id}, resolving the census from the process) and raise
188
//        @Description        each affected on-chain election's maxCensusSize so the new members can vote. Members are
189
//        @Description        added synchronously; the maxCensusSize update runs as an async job (poll GET /jobs/{jobId}).
190
//        @Description        Questions with an eligibility subset keep their fixed size and are unaffected. Requires
191
//        @Description        Manager/Admin role and is subject to the plan's census quota.
192
//        @Tags                        processes
193
//        @Accept                        json
194
//        @Produce                json
195
//        @Security                BearerAuth
196
//        @Param                        processId        path                string                                                                        true        "Process ID"
197
//        @Param                        request                body                apicommon.AddCensusParticipantsRequest        true        "Member IDs to add"
198
//        @Success                200                        {object}        apicommon.UpdateProcessCensusResponse        "Members added; no on-chain resize needed"
199
//        @Success                202                        {object}        apicommon.UpdateProcessCensusResponse        "Members added; maxCensusSize update enqueued"
200
//        @Failure                400                        {object}        errors.Error                                                        "Invalid input data"
201
//        @Failure                401                        {object}        errors.Error                                                        "Unauthorized"
202
//        @Failure                404                        {object}        errors.Error                                                        "Process not found"
203
//        @Failure                409                        {object}        errors.Error                                                        "Process is not published"
204
//        @Failure                500                        {object}        errors.Error                                                        "Internal server error"
205
//        @Router                        /processes/{processId}/census [put]
206
func (a *API) updateVotingProcessCensusHandler(w http.ResponseWriter, r *http.Request) {
1✔
207
        oid, ok := a.votingProcessID(w, r)
1✔
208
        if !ok {
1✔
NEW
209
                return
×
NEW
210
        }
×
211
        // loads the process + questions and gates on Manager/Admin of the owning org.
212
        vp, questions, ok := a.authorizeStatusChange(w, r, oid)
1✔
213
        if !ok {
1✔
NEW
214
                return
×
NEW
215
        }
×
216
        // only a published process can have its on-chain census extended; drafts use PUT /processes.
217
        if !vp.Published {
1✔
NEW
218
                errors.ErrDuplicateConflict.Withf("process is not published; edit the draft via PUT /processes/{processId}").Write(w)
×
NEW
219
                return
×
NEW
220
        }
×
221
        census, err := a.db.Census(vp.CensusID.Hex())
1✔
222
        if err != nil {
1✔
NEW
223
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
224
                return
×
NEW
225
        }
×
226

227
        var req apicommon.AddCensusParticipantsRequest
1✔
228
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1✔
NEW
229
                errors.ErrMalformedBody.Withf("couldn't decode participant IDs").Write(w)
×
NEW
230
                return
×
NEW
231
        }
×
232
        if len(req.MemberIDs) == 0 {
1✔
NEW
233
                apicommon.HTTPWriteJSON(w, &apicommon.UpdateProcessCensusResponse{Added: 0})
×
NEW
234
                return
×
NEW
235
        }
×
236

237
        if err := a.subscriptions.OrgCanAddCensusParticipants(census.OrgAddress, census.ID.Hex(), len(req.MemberIDs)); err != nil {
1✔
NEW
238
                writeSubscriptionError(w, err)
×
NEW
239
                return
×
NEW
240
        }
×
241

242
        added, memberErrs, err := a.db.AddCensusParticipantsByMemberIDs(census.ID.Hex(), req.MemberIDs)
1✔
243
        switch {
1✔
244
        case err == nil:
1✔
NEW
245
        case stderrors.Is(err, db.ErrInvalidData), stderrors.Is(err, db.ErrUpdateWouldCreateDuplicates):
×
NEW
246
                errors.ErrInvalidData.WithErr(err).Write(w)
×
NEW
247
                return
×
NEW
248
        case stderrors.Is(err, db.ErrNotFound):
×
NEW
249
                errors.ErrCensusNotFound.Write(w)
×
NEW
250
                return
×
NEW
251
        default:
×
NEW
252
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
253
                return
×
254
        }
255

256
        // recount and persist the census size so the on-chain maxCensusSize we set below is correct.
257
        size, err := a.db.CountCensusParticipants(census.ID.Hex())
1✔
258
        if err != nil {
1✔
NEW
259
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
260
                return
×
NEW
261
        }
×
262
        census.Size = size
1✔
263
        if _, err := a.db.SetCensus(census); err != nil {
1✔
NEW
264
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
265
                return
×
NEW
266
        }
×
267

268
        jobID, ok := a.enqueueCensusSizeUpdate(w, vp, census, questions)
1✔
269
        if !ok {
1✔
NEW
270
                return
×
NEW
271
        }
×
272
        resp := &apicommon.UpdateProcessCensusResponse{JobID: jobID, Added: uint32(added), Errors: memberErrs}
1✔
273
        if jobID == "" {
1✔
NEW
274
                // no whole-census election to resize (subset-only questions): nothing async is pending, so 200.
×
NEW
275
                apicommon.HTTPWriteJSON(w, resp)
×
NEW
276
                return
×
NEW
277
        }
×
278
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, resp)
1✔
279
}
280

281
// enqueueCensusSizeUpdate submits a SET_PROCESS_CENSUS tx per published whole-census question to raise
282
// its on-chain maxCensusSize to the census's new size. Questions with an eligibility subset keep their
283
// fixed size and are skipped. Returns the job id (empty when nothing on-chain needs updating) and false
284
// on failure (after writing the error response).
285
func (a *API) enqueueCensusSizeUpdate(
286
        w http.ResponseWriter, vp *db.VotingProcess, census *db.Census, questions []db.VotingProcessQuestion,
287
) (string, bool) {
1✔
288
        published := make([]db.VotingProcessQuestion, 0, len(questions))
1✔
289
        for i := range questions {
3✔
290
                if len(questions[i].UpstreamID) > 0 && len(questions[i].EligibleMemberIDs) == 0 {
3✔
291
                        published = append(published, questions[i])
1✔
292
                }
1✔
293
        }
294
        if len(published) == 0 {
1✔
NEW
295
                return "", true // no whole-census election on chain: nothing to resize
×
NEW
296
        }
×
297
        org, err := a.db.Organization(vp.OrgAddress)
1✔
298
        if err != nil {
1✔
NEW
299
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
300
                return "", false
×
NEW
301
        }
×
302
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
1✔
303
        if err != nil {
1✔
NEW
304
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
NEW
305
                return "", false
×
NEW
306
        }
×
307
        jobID, err := apicommon.NewJobID()
1✔
308
        if err != nil {
1✔
NEW
309
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
310
                return "", false
×
NEW
311
        }
×
312
        if err := a.db.CreateTxJob(jobID, db.JobTypeSetProcessCensus, org.Address); err != nil {
1✔
NEW
313
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
314
                return "", false
×
NEW
315
        }
×
316
        root := census.Published.Root
1✔
317
        uri := census.Published.URI
1✔
318
        size := uint64(census.Size)
1✔
319
        orgLock := a.orgTxLocks.lock(org.Address)
1✔
320
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
2✔
321
                defer orgLock.Unlock()
1✔
322
                for i := range published {
2✔
323
                        tx, err := a.account.BuildSetProcessCensusTx(orgSigner.Address(), published[i].UpstreamID, root, uri, size)
1✔
324
                        if err != nil {
1✔
NEW
325
                                return nil, err
×
NEW
326
                        }
×
327
                        fundedTx, _, err := a.account.FundTransaction(tx, orgSigner.Address())
1✔
328
                        if err != nil {
1✔
NEW
329
                                return nil, err
×
NEW
330
                        }
×
331
                        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
1✔
332
                        if err != nil {
1✔
NEW
333
                                return nil, err
×
NEW
334
                        }
×
335
                        if _, err := a.account.SubmitSignedTx(stx); err != nil {
1✔
NEW
336
                                return nil, err
×
NEW
337
                        }
×
338
                }
339
                return &db.JobResult{Status: string(db.JobStatusCompleted)}, nil
1✔
NEW
340
        }}) {
×
NEW
341
                orgLock.Unlock()
×
NEW
342
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
NEW
343
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
NEW
344
                }
×
NEW
345
                errors.ErrTxQueueFull.Write(w)
×
NEW
346
                return "", false
×
347
        }
348
        return jobID, true
1✔
349
}
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