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

vocdoni / saas-backend / 27875914726

20 Jun 2026 03:42PM UTC coverage: 61.126% (+1.4%) from 59.752%
27875914726

push

github

p4u
fix(api): harden async tx publish/status paths against races

Serialize per-org build->sign->submit, claim drafts atomically for publish,
fail orphaned jobs on a full tx queue, and range-check election start/duration.

70 of 115 new or added lines in 6 files covered. (60.87%)

427 existing lines in 13 files now uncovered.

9013 of 14745 relevant lines covered (61.13%)

43.92 hits per line

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

49.54
/api/process.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/account"
12
        "github.com/vocdoni/saas-backend/api/apicommon"
13
        "github.com/vocdoni/saas-backend/db"
14
        "github.com/vocdoni/saas-backend/errors"
15
        "github.com/vocdoni/saas-backend/internal"
16
        "github.com/vocdoni/saas-backend/subscriptions"
17
        "go.mongodb.org/mongo-driver/bson/primitive"
18
        "go.vocdoni.io/dvote/log"
19
        "go.vocdoni.io/proto/build/go/models"
20
)
21

22
// createProcessHandler godoc
23
//
24
//        @Summary                Create a new voting process
25
//        @Description        Create a new voting process. Requires Manager/Admin role.
26
//        @Tags                        process
27
//        @Accept                        json
28
//        @Produce                json
29
//        @Security                BearerAuth
30
//        @Param                        request        body                apicommon.CreateProcessRequest        true        "Process creation information"
31
//        @Success                200                {object}        primitive.ObjectID                                "Process ID"
32
//        @Failure                400                {object}        errors.Error                                        "Invalid input data"
33
//        @Failure                401                {object}        errors.Error                                        "Unauthorized"
34
//        @Failure                404                {object}        errors.Error                                        "Published census not found"
35
//        @Failure                409                {object}        errors.Error                                        "Process already exists"
36
//        @Failure                500                {object}        errors.Error                                        "Internal server error"
37
//        @Router                        /process [post]
38
func (a *API) createProcessHandler(w http.ResponseWriter, r *http.Request) {
15✔
39
        // parse the process info from the request body
15✔
40
        processInfo := &apicommon.CreateProcessRequest{}
15✔
41
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
15✔
UNCOV
42
                errors.ErrMalformedBody.Write(w)
×
43
                return
×
44
        }
×
45

46
        // get the user from the request context
47
        user, ok := apicommon.UserFromContext(r.Context())
15✔
48
        if !ok {
15✔
UNCOV
49
                errors.ErrUnauthorized.Write(w)
×
UNCOV
50
                return
×
UNCOV
51
        }
×
52

53
        // if it's a draft process
54
        if processInfo.Address.Equals(nil) && processInfo.OrgAddress == (common.Address{}) {
17✔
55
                errors.ErrMalformedBody.Withf("draft processes must provide an org address").Write(w)
2✔
56
                return
2✔
57
        }
2✔
58

59
        // Create or update the process
60
        process := &db.Process{
13✔
61
                Metadata:       processInfo.Metadata,
13✔
62
                ElectionParams: processInfo.ElectionParams,
13✔
63
        }
13✔
64

13✔
65
        var orgAddress common.Address
13✔
66
        if processInfo.CensusID != nil {
25✔
67
                var err error
12✔
68
                census, err := a.db.Census(processInfo.CensusID.String())
12✔
69
                if err != nil {
12✔
UNCOV
70
                        if err == db.ErrNotFound {
×
UNCOV
71
                                errors.ErrMalformedURLParam.Withf("invalid census provided").Write(w)
×
72
                                return
×
73
                        }
×
74
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
75
                        return
×
76
                }
77
                process.Census = *census
12✔
78
                orgAddress = census.OrgAddress
12✔
79
        } else if processInfo.OrgAddress != (common.Address{}) {
2✔
80
                orgAddress = processInfo.OrgAddress
1✔
81
        } else {
1✔
82
                errors.ErrMalformedBody.Withf("either census ID or organization address must be provided").Write(w)
×
83
                return
×
UNCOV
84
        }
×
85

86
        // check the user has the necessary permissions
87
        if !user.HasRoleFor(orgAddress, db.ManagerRole) && !user.HasRoleFor(orgAddress, db.AdminRole) {
13✔
UNCOV
88
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
UNCOV
89
                return
×
UNCOV
90
        }
×
91

92
        process.OrgAddress = orgAddress
13✔
93
        if !processInfo.Address.Equals(nil) {
15✔
94
                process.Address = processInfo.Address
2✔
95
        }
2✔
96

97
        // if it's a new draft process
98
        if process.Address.Equals(nil) && process.ID == primitive.NilObjectID {
24✔
99
                if err := a.subscriptions.OrgHasPermission(process.OrgAddress, subscriptions.CreateDraft); err != nil {
13✔
100
                        if apierr, ok := err.(errors.Error); ok {
4✔
101
                                apierr.Write(w)
2✔
102
                                return
2✔
103
                        }
2✔
104
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
105
                        return
×
106
                }
107
        }
108

109
        processID, err := a.db.SetProcess(process)
11✔
110
        if err != nil {
11✔
UNCOV
111
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
112
                return
×
UNCOV
113
        }
×
114

115
        apicommon.HTTPWriteJSON(w, processID)
11✔
116
}
117

118
// updateProcessHandler godoc
119
//
120
//        @Summary                Update an existing voting process
121
//        @Description        Update an existing voting process. Requires Manager/Admin role.
122
//        @Tags                        process
123
//        @Accept                        json
124
//        @Produce                json
125
//        @Security                BearerAuth
126
//        @Param                        processId        path                string                                                        true        "Process ID"
127
//        @Param                        request                body                apicommon.CreateProcessRequest        true        "Process update information"
128
//        @Success                200                        {string}        string                                                        "OK"
129
//        @Failure                400                        {object}        errors.Error                                        "Invalid input data"
130
//        @Failure                401                        {object}        errors.Error                                        "Unauthorized"
131
//        @Failure                404                        {object}        errors.Error                                        "Process not found"
132
//        @Failure                500                        {object}        errors.Error                                        "Internal server error"
133
//        @Router                        /process/{processId} [put]
134
func (a *API) updateProcessHandler(w http.ResponseWriter, r *http.Request) {
2✔
135
        processID := chi.URLParam(r, "processId")
2✔
136
        if processID == "" {
2✔
137
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
UNCOV
138
                return
×
UNCOV
139
        }
×
140
        parsedID, err := primitive.ObjectIDFromHex(processID)
2✔
141
        if err != nil {
2✔
142
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
143
                return
×
144
        }
×
145

146
        // parse the process info from the request body
147
        processInfo := &apicommon.UpdateProcessRequest{}
2✔
148
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
2✔
149
                errors.ErrMalformedBody.Write(w)
×
150
                return
×
151
        }
×
152

153
        // get the user from the request context
154
        user, ok := apicommon.UserFromContext(r.Context())
2✔
155
        if !ok {
2✔
156
                errors.ErrUnauthorized.Write(w)
×
157
                return
×
158
        }
×
159

160
        existingProcess, err := a.db.Process(parsedID)
2✔
161
        if err != nil {
2✔
UNCOV
162
                if err == db.ErrNotFound {
×
UNCOV
163
                        errors.ErrProcessNotFound.Write(w)
×
UNCOV
164
                        return
×
UNCOV
165
                }
×
UNCOV
166
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
167
                return
×
168
        }
169

170
        // check if it's a draft process and can be overwritten
171
        if !existingProcess.Address.Equals(nil) {
3✔
172
                errors.ErrDuplicateConflict.Withf("process already exists and is not in draft mode").Write(w)
1✔
173
                return
1✔
174
        }
1✔
175

176
        // check the user has the necessary permissions
177
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
1✔
178
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
1✔
179
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
180
                return
×
181
        }
×
182

183
        var census *db.Census
1✔
184
        if !processInfo.CensusID.Equals(nil) {
1✔
185
                census, err = a.db.Census(processInfo.CensusID.String())
×
UNCOV
186
                if err != nil {
×
187
                        if err == db.ErrNotFound {
×
UNCOV
188
                                errors.ErrMalformedURLParam.Withf("census not found").Write(w)
×
UNCOV
189
                                return
×
UNCOV
190
                        }
×
UNCOV
191
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
192
                        return
×
193
                }
UNCOV
194
                existingProcess.Census = *census
×
195
        }
196

197
        if len(processInfo.Metadata) > 0 {
2✔
198
                existingProcess.Metadata = processInfo.Metadata
1✔
199
        }
1✔
200

201
        if processInfo.ElectionParams != nil {
1✔
202
                existingProcess.ElectionParams = processInfo.ElectionParams
×
UNCOV
203
        }
×
204

205
        if !processInfo.Address.Equals(nil) {
2✔
206
                existingProcess.Address = processInfo.Address
1✔
207
        }
1✔
208

209
        _, err = a.db.SetProcess(existingProcess)
1✔
210
        if err != nil {
1✔
UNCOV
211
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
212
                return
×
UNCOV
213
        }
×
214

215
        apicommon.HTTPWriteOK(w)
1✔
216
}
217

218
// processInfoHandler godoc
219
//
220
//        @Summary                Get process information
221
//        @Description        Retrieve voting process information by ID. Returns process details including census and metadata.
222
//        @Tags                        process
223
//        @Accept                        json
224
//        @Produce                json
225
//        @Param                        processId        path                string        true        "Process ID"
226
//        @Success                200                        {object}        db.Process
227
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
228
//        @Failure                404                        {object}        errors.Error        "Process not found"
229
//        @Failure                500                        {object}        errors.Error        "Internal server error"
230
//        @Router                        /process/{processId} [get]
231
func (a *API) processInfoHandler(w http.ResponseWriter, r *http.Request) {
12✔
232
        processID := chi.URLParam(r, "processId")
12✔
233
        if len(processID) == 0 {
12✔
UNCOV
234
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
UNCOV
235
                return
×
UNCOV
236
        }
×
237
        parsedID, err := primitive.ObjectIDFromHex(processID)
12✔
238
        if err != nil {
13✔
239
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
1✔
240
                return
1✔
241
        }
1✔
242

243
        process, err := a.db.Process(parsedID)
11✔
244
        if err != nil {
12✔
245
                if err == db.ErrNotFound {
2✔
246
                        errors.ErrProcessNotFound.Write(w)
1✔
247
                        return
1✔
248
                }
1✔
UNCOV
249
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
250
                return
×
251
        }
252

253
        apicommon.HTTPWriteJSON(w, process)
10✔
254
}
255

256
// organizationListProcessDraftsHandler godoc
257
//
258
//        @Summary                Get paginated list of process drafts
259
//        @Description        Returns a list of voting process drafts.
260
//        @Tags                        process
261
//        @Accept                        json
262
//        @Produce                json
263
//        @Success                200        {object}        apicommon.ListOrganizationProcesses
264
//        @Failure                404        {object}        errors.Error        "Process not found"
265
//        @Failure                500        {object}        errors.Error        "Internal server error"
266
//        @Router                        /organizations/{address}/processes/drafts [get]
267
func (a *API) organizationListProcessDraftsHandler(w http.ResponseWriter, r *http.Request) {
4✔
268
        // get the organization info from the request context
4✔
269
        org, _, ok := a.organizationFromRequest(r)
4✔
270
        if !ok {
4✔
271
                errors.ErrNoOrganizationProvided.Write(w)
×
272
                return
×
273
        }
×
274
        // get the user from the request context
275
        user, ok := apicommon.UserFromContext(r.Context())
4✔
276
        if !ok {
4✔
277
                errors.ErrUnauthorized.Write(w)
×
278
                return
×
279
        }
×
280
        // check the user has the necessary permissions
281
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
4✔
UNCOV
282
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
UNCOV
283
                return
×
284
        }
×
285

286
        params, err := parsePaginationParams(r.URL.Query().Get(ParamPage), r.URL.Query().Get(ParamLimit))
4✔
287
        if err != nil {
4✔
UNCOV
288
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
289
                return
×
290
        }
×
291

292
        // retrieve the orgMembers with pagination
293
        totalItems, processes, err := a.db.ListProcesses(org.Address, params.Page, params.Limit, db.DraftOnly)
4✔
294
        if err != nil {
4✔
UNCOV
295
                errors.ErrGenericInternalServerError.Withf("could not get processes: %v", err).Write(w)
×
UNCOV
296
                return
×
UNCOV
297
        }
×
298
        pagination, err := calculatePagination(params.Page, params.Limit, totalItems)
4✔
299
        if err != nil {
4✔
UNCOV
300
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
UNCOV
301
                return
×
UNCOV
302
        }
×
303

304
        apicommon.HTTPWriteJSON(w, &apicommon.ListOrganizationProcesses{
4✔
305
                Pagination: pagination,
4✔
306
                Processes:  processes,
4✔
307
        })
4✔
308
}
309

310
// deleteProcessHandler godoc
311
//
312
//        @Summary                Delete a voting process
313
//        @Description        Delete a voting process. Requires Manager/Admin role.
314
//        @Tags                        process
315
//        @Accept                        json
316
//        @Produce                json
317
//        @Security                BearerAuth
318
//        @Param                        processId        path                string                        true        "Process ID"
319
//        @Success                200                        {string}        string                        "OK"
320
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
321
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
322
//        @Failure                404                        {object}        errors.Error        "Process not found"
323
//        @Failure                500                        {object}        errors.Error        "Internal server error"
324
//        @Router                        /process/{processId} [delete]
325
func (a *API) deleteProcessHandler(w http.ResponseWriter, r *http.Request) {
1✔
326
        processID := chi.URLParam(r, "processId")
1✔
327
        if processID == "" {
1✔
UNCOV
328
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
329
                return
×
330
        }
×
331
        parsedID, err := primitive.ObjectIDFromHex(processID)
1✔
332
        if err != nil {
1✔
UNCOV
333
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
UNCOV
334
                return
×
335
        }
×
336

337
        // get the user from the request context
338
        user, ok := apicommon.UserFromContext(r.Context())
1✔
339
        if !ok {
1✔
340
                errors.ErrUnauthorized.Write(w)
×
UNCOV
341
                return
×
UNCOV
342
        }
×
343

344
        existingProcess, err := a.db.Process(parsedID)
1✔
345
        if err != nil {
1✔
346
                if err == db.ErrNotFound {
×
347
                        errors.ErrProcessNotFound.Withf("process not found").Write(w)
×
348
                        return
×
UNCOV
349
                }
×
UNCOV
350
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
351
                return
×
352
        }
353

354
        // check the user has the necessary permissions
355
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
1✔
356
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
1✔
UNCOV
357
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
UNCOV
358
                return
×
UNCOV
359
        }
×
360

361
        err = a.db.DelProcess(parsedID)
1✔
362
        if err != nil {
1✔
UNCOV
363
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
364
                return
×
UNCOV
365
        }
×
366

367
        apicommon.HTTPWriteOK(w)
1✔
368
}
369

370
// publishProcessHandler godoc
371
//
372
//        @Summary                Publish a draft process as an on-chain election
373
//        @Description        Publishes an existing draft process (created via POST /process) as an on-chain
374
//        @Description        election. The backend builds the election metadata and NewProcess transaction,
375
//        @Description        funds and signs it (with the organization signer) synchronously, then submits and
376
//        @Description        confirms it on a background worker; the call returns 202 with a job id. Poll GET
377
//        @Description        /jobs/{jobId} for the resulting on-chain id. Requires Admin role. Idempotent: if
378
//        @Description        the draft is already published its on-chain id is returned with 200 without sending
379
//        @Description        a new transaction.
380
//        @Tags                        process
381
//        @Accept                        json
382
//        @Produce                json
383
//        @Security                BearerAuth
384
//        @Param                        processId        path                string                                                                true        "Draft process ID"
385
//        @Success                202                        {object}        apicommon.EnqueuedResponse                        "Publish accepted; poll GET /jobs/{jobId}"
386
//        @Success                200                        {object}        apicommon.PublishProcessResponse        "Already published (idempotent): on-chain id and status"
387
//        @Failure                400                        {object}        errors.Error                                                "Invalid input data"
388
//        @Failure                401                        {object}        errors.Error                                                "Unauthorized"
389
//        @Failure                404                        {object}        errors.Error                                                "Process not found"
390
//        @Failure                409                        {object}        errors.Error                                                "A publish is already in progress for this draft"
391
//        @Failure                500                        {object}        errors.Error                                                "Internal server error"
392
//        @Failure                503                        {object}        errors.Error                                                "Transaction queue is full"
393
//        @Router                        /process/{processId}/publish [post]
394
func (a *API) publishProcessHandler(w http.ResponseWriter, r *http.Request) {
6✔
395
        processID := chi.URLParam(r, "processId")
6✔
396
        if processID == "" {
6✔
UNCOV
397
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
UNCOV
398
                return
×
UNCOV
399
        }
×
400
        parsedID, err := primitive.ObjectIDFromHex(processID)
6✔
401
        if err != nil {
6✔
UNCOV
402
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
UNCOV
403
                return
×
UNCOV
404
        }
×
405

406
        user, ok := apicommon.UserFromContext(r.Context())
6✔
407
        if !ok {
6✔
UNCOV
408
                errors.ErrUnauthorized.Write(w)
×
UNCOV
409
                return
×
UNCOV
410
        }
×
411

412
        draft, err := a.db.Process(parsedID)
6✔
413
        if err != nil {
6✔
UNCOV
414
                if err == db.ErrNotFound {
×
UNCOV
415
                        errors.ErrProcessNotFound.Write(w)
×
UNCOV
416
                        return
×
UNCOV
417
                }
×
UNCOV
418
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
419
                return
×
420
        }
421

422
        // permission: Admin of the owning organization. Publishing maps to a NEW_PROCESS
423
        // tx, which subscriptions.HasTxPermission requires Admin for, so we enforce the
424
        // same role here rather than letting a Manager pass and be rejected downstream.
425
        if !user.HasRoleFor(draft.OrgAddress, db.AdminRole) {
6✔
UNCOV
426
                errors.ErrUnauthorized.Withf("user is not admin of the organization that owns this process").Write(w)
×
UNCOV
427
                return
×
UNCOV
428
        }
×
429

430
        // idempotent: already published
431
        if !draft.Address.Equals(nil) {
7✔
432
                apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: draft.Address, Status: draft.Status})
1✔
433
                return
1✔
434
        }
1✔
435

436
        // a draft must carry the high-level election definition to be publishable.
437
        // the on-chain census root is always the CSP public key, so the draft census
438
        // (member list used later by the CSP bundle flow) is not required here.
439
        if draft.ElectionParams == nil {
5✔
UNCOV
440
                errors.ErrMalformedBody.Withf("draft has no election params").Write(w)
×
UNCOV
441
                return
×
UNCOV
442
        }
×
443

444
        // atomically claim the draft for publishing BEFORE any expensive metadata/funding/
445
        // signing work. This single conditional update is the authoritative duplicate-publish
446
        // guard: two concurrent publishes cannot both win the claim, so only one NEW_PROCESS
447
        // tx is ever built for a draft.
448
        claimed, err := a.db.ClaimProcessForPublish(parsedID)
5✔
449
        if err != nil {
5✔
NEW
450
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
NEW
451
                return
×
NEW
452
        }
×
453
        if !claimed {
5✔
NEW
454
                // lost the claim: either another request is mid-publish, or it already finished.
×
NEW
455
                if cur, e := a.db.Process(parsedID); e == nil && !cur.Address.Equals(nil) {
×
NEW
456
                        apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: cur.Address, Status: cur.Status})
×
NEW
457
                        return
×
NEW
458
                }
×
NEW
459
                errors.ErrPublishInProgress.Write(w)
×
NEW
460
                return
×
461
        }
462

463
        // from here the draft is in PUBLISHING. Until a worker owns the job, every failure
464
        // path must release the claim, otherwise the draft is stuck (the release must $unset
465
        // status, since a zero-value string write is dropped by the dynamic update helper).
466
        committed := false
5✔
467
        defer func() {
10✔
468
                if committed {
9✔
469
                        return
4✔
470
                }
4✔
471
                if e := a.db.ClearProcessPublishing(parsedID); e != nil {
1✔
NEW
472
                        log.Warnw("could not clear publishing state after failed publish", "error", e)
×
NEW
473
                }
×
474
        }()
475

476
        org, err := a.db.Organization(draft.OrgAddress)
5✔
477
        if err != nil {
5✔
UNCOV
478
                if err == db.ErrNotFound {
×
UNCOV
479
                        errors.ErrOrganizationNotFound.Write(w)
×
UNCOV
480
                        return
×
UNCOV
481
                }
×
UNCOV
482
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
483
                return
×
484
        }
485

486
        // if this org is managed by an integrator, atomically reserve the integrator's
487
        // aggregate process/census quota BEFORE building the on-chain tx, so concurrent
488
        // publishes cannot each pass a stale check and exceed the cap. The reservation is
489
        // rolled back (deferred) unless the publish commits. Test-sized elections are exempt
490
        // from the integrator quota, mirroring the per-org Processes counter exemption below.
491
        managedReserved := false
5✔
492
        var integratorAddr common.Address
5✔
493
        nonTestSized := draft.ElectionParams.MaxCensusSize > uint64(db.TestMaxCensusSize)
5✔
494
        if org.ManagedBy != (common.Address{}) && nonTestSized {
7✔
495
                integrator, err := a.db.Organization(org.ManagedBy)
2✔
496
                if err != nil {
2✔
UNCOV
497
                        errors.ErrGenericInternalServerError.Withf("could not get integrator organization: %v", err).Write(w)
×
UNCOV
498
                        return
×
UNCOV
499
                }
×
500
                limits, err := a.subscriptions.EffectiveIntegratorLimits(integrator)
2✔
501
                if err != nil {
2✔
UNCOV
502
                        if apiErr, ok := err.(errors.Error); ok {
×
UNCOV
503
                                apiErr.Write(w)
×
UNCOV
504
                                return
×
UNCOV
505
                        }
×
UNCOV
506
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
507
                        return
×
508
                }
509
                if err := a.db.ReserveManagedPublish(integrator.Address,
2✔
510
                        limits.MaxManagedProcesses, limits.MaxManagedCensusSize, int(draft.ElectionParams.MaxCensusSize)); err != nil {
3✔
511
                        if err == db.ErrManagedQuotaReached {
2✔
512
                                errors.ErrIntegratorQuotaExceeded.Write(w)
1✔
513
                                return
1✔
514
                        }
1✔
UNCOV
515
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
516
                        return
×
517
                }
518
                managedReserved = true
1✔
519
                integratorAddr = integrator.Address
1✔
520
                // roll back the reservation if the publish is not handed to a worker (any
1✔
521
                // synchronous failure below, or a full queue). Once enqueued the worker owns
1✔
522
                // the reservation outcome and clears managedReserved.
1✔
523
                defer func() {
2✔
524
                        if !managedReserved {
2✔
525
                                return
1✔
526
                        }
1✔
UNCOV
527
                        if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
×
UNCOV
528
                                log.Warnw("could not roll back managed processes counter", "error", e)
×
UNCOV
529
                        }
×
UNCOV
530
                        if e := a.db.AddOrganizationManagedCensusSize(integratorAddr,
×
UNCOV
531
                                -int64(draft.ElectionParams.MaxCensusSize)); e != nil {
×
UNCOV
532
                                log.Warnw("could not roll back managed census counter", "error", e)
×
UNCOV
533
                        }
×
534
                }()
535
        }
536

537
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
4✔
538
        if err != nil {
4✔
UNCOV
539
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
UNCOV
540
                return
×
UNCOV
541
        }
×
542

543
        cspPubKey, err := a.csp.PubKey()
4✔
544
        if err != nil {
4✔
UNCOV
545
                errors.ErrGenericInternalServerError.Withf("could not get csp public key: %v", err).Write(w)
×
UNCOV
546
                return
×
UNCOV
547
        }
×
548

549
        // build + store the election metadata content-addressed; its public URL is the
550
        // on-chain metadata pointer (must be known before the tx, so it cannot contain
551
        // the not-yet-known process id).
552
        metaBytes, err := account.BuildElectionMetadata(draft.ElectionParams)
4✔
553
        if err != nil {
4✔
UNCOV
554
                errors.ErrMalformedBody.Withf("invalid election params: %v", err).Write(w)
×
UNCOV
555
                return
×
UNCOV
556
        }
×
557
        objectName, err := a.objectStorage.PutJSON(metaBytes, user.Email)
4✔
558
        if err != nil {
4✔
UNCOV
559
                errors.ErrInternalStorageError.WithErr(err).Write(w)
×
UNCOV
560
                return
×
UNCOV
561
        }
×
562
        metadataURL := fmt.Sprintf("%s/storage/%s", a.serverURL, objectName)
4✔
563

4✔
564
        // serialize build->sign->submit per organization so a concurrent publish or status
4✔
565
        // change for the same org cannot read the same account nonce and sign a conflicting
4✔
566
        // tx. The worker releases the lock after submit (held across the async hand-off);
4✔
567
        // every synchronous failure below releases it via the deferred unlock.
4✔
568
        orgLock := a.orgTxLocks.lock(org.Address)
4✔
569
        lockHeld := true
4✔
570
        defer func() {
8✔
571
                if lockHeld {
4✔
NEW
572
                        orgLock.Unlock()
×
NEW
573
                }
×
574
        }()
575

576
        // build the NewProcess tx (CSP census)
577
        tx, err := a.account.BuildNewProcessTx(&account.NewProcessParams{
4✔
578
                OrgAddress:  draft.OrgAddress,
4✔
579
                Params:      draft.ElectionParams,
4✔
580
                CensusRoot:  cspPubKey,
4✔
581
                CensusURI:   a.serverURL,
4✔
582
                MetadataURL: metadataURL,
4✔
583
        })
4✔
584
        if err != nil {
4✔
UNCOV
585
                errors.ErrMalformedBody.Withf("could not build election: %v", err).Write(w)
×
UNCOV
586
                return
×
UNCOV
587
        }
×
588

589
        // fund
590
        fundedTx, txType, err := a.account.FundTransaction(tx, orgSigner.Address())
4✔
591
        if err != nil {
4✔
UNCOV
592
                if apiErr, ok := err.(errors.Error); ok {
×
UNCOV
593
                        apiErr.Write(w)
×
UNCOV
594
                        return
×
UNCOV
595
                }
×
UNCOV
596
                errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
UNCOV
597
                return
×
598
        }
599
        if txType == nil || *txType != models.TxType_NEW_PROCESS {
4✔
UNCOV
600
                errors.ErrInvalidTxFormat.With("unexpected tx type for publish").Write(w)
×
UNCOV
601
                return
×
UNCOV
602
        }
×
603

604
        // quota / permission (same engine as the /transactions path)
605
        if hasPermission, err := a.subscriptions.HasTxPermission(fundedTx, *txType, org, user); !hasPermission || err != nil {
4✔
UNCOV
606
                errors.ErrUnauthorized.Withf("user does not have permission to publish: %v", err).Write(w)
×
UNCOV
607
                return
×
UNCOV
608
        }
×
609

610
        // sign with the organization signer
611
        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
4✔
612
        if err != nil {
4✔
UNCOV
613
                errors.ErrGenericInternalServerError.Withf("could not sign election tx: %v", err).Write(w)
×
UNCOV
614
                return
×
UNCOV
615
        }
×
616

617
        jobID, err := apicommon.NewJobID()
4✔
618
        if err != nil {
4✔
UNCOV
619
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
620
                return
×
UNCOV
621
        }
×
622
        if err := a.db.CreateTxJob(jobID, db.JobTypePublishProcess, org.Address); err != nil {
4✔
UNCOV
623
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
UNCOV
624
                return
×
UNCOV
625
        }
×
626

627
        // submit + confirm on the worker pool. On success the draft becomes READY with its
628
        // on-chain id; on failure the PUBLISHING claim is released and any managed reservation
629
        // is rolled back. The idempotency guard keys off draft.Address, so a retry after
630
        // failure cannot create a duplicate election.
631
        reserved := managedReserved
4✔
632
        censusSize := int64(draft.ElectionParams.MaxCensusSize)
4✔
633
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
8✔
634
                defer orgLock.Unlock()
4✔
635
                data, err := a.account.SubmitSignedTx(stx)
4✔
636
                if err != nil {
4✔
NEW
637
                        if e := a.db.ClearProcessPublishing(parsedID); e != nil {
×
NEW
638
                                log.Warnw("could not clear publishing state after failed publish", "error", e)
×
UNCOV
639
                        }
×
UNCOV
640
                        if reserved {
×
UNCOV
641
                                if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
×
UNCOV
642
                                        log.Warnw("could not roll back managed processes counter", "error", e)
×
UNCOV
643
                                }
×
UNCOV
644
                                if e := a.db.AddOrganizationManagedCensusSize(integratorAddr, -censusSize); e != nil {
×
UNCOV
645
                                        log.Warnw("could not roll back managed census counter", "error", e)
×
UNCOV
646
                                }
×
647
                        }
UNCOV
648
                        return nil, err
×
649
                }
650
                draft.Address = internal.HexBytes(data)
4✔
651
                draft.Status = "READY"
4✔
652
                draft.PublishedAt = time.Now()
4✔
653
                if _, err := a.db.SetProcess(draft); err != nil {
4✔
UNCOV
654
                        return nil, err
×
UNCOV
655
                }
×
656
                // best-effort per-org Processes counter; only count non-test-sized elections.
657
                if nonTestSized {
8✔
658
                        if err := a.db.IncrementOrganizationProcessesCounter(org.Address); err != nil {
4✔
UNCOV
659
                                log.Warnw("could not update organization process counter", "error", err)
×
UNCOV
660
                        }
×
661
                }
662
                return &db.JobResult{Address: draft.Address, Status: "READY"}, nil
4✔
UNCOV
663
        }}) {
×
NEW
664
                // full queue: mark the job failed so it is not orphaned pending; the deferred
×
NEW
665
                // unlock, publishing-claim release and reservation rollback all fire on return.
×
NEW
666
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
NEW
667
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
UNCOV
668
                }
×
UNCOV
669
                errors.ErrTxQueueFull.Write(w)
×
UNCOV
670
                return
×
671
        }
672
        // handed to a worker: it now owns the publish claim, reservation and org lock.
673
        committed = true
4✔
674
        managedReserved = false
4✔
675
        lockHeld = false
4✔
676

4✔
677
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, &apicommon.EnqueuedResponse{JobID: jobID})
4✔
678
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc