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

vocdoni / saas-backend / 28188283899

25 Jun 2026 05:24PM UTC coverage: 62.203% (+0.1%) from 62.083%
28188283899

push

github

web-flow
fix(api): resolve election metadata from a stored reference on GET /process/{id} (#553)

* fix(api): resolve election metadata from a stored reference on GET /process/{id}

The Vochain node only resolves ipfs:// metadata pointers, so the https
/storage pointer this service publishes is never resolved downstream and the
election metadata goes missing.

Store a generic reference (Process.MetadataURL) and resolve it on read:
- our own object storage (matched first, works even when serverURL is empty) ->
  read locally;
- ipfs:// -> resolve via the Vochain, then cache locally and promote the
  reference so later reads stay local;
- other http(s) -> best-effort external fetch (3s timeout, 1 MiB cap).

The reference is bootstrapped from the on-chain pointer when missing (covers
pre-existing/external processes, no migration). Resolution is best-effort: any
error logs and returns the stored value. Drafts (no on-chain address) keep
their stored metadata map.

* fix(api): cache externally-fetched metadata too, not just ipfs

Metadata is immutable (a change is a republish through this API), so the
external http(s) case is as safe to cache as ipfs. Unify both remote branches:
any remotely-resolved document is stored locally and the reference promoted, so
later reads resolve from our own storage with no external/Vochain round-trip.

* fix(api): address Copilot review on metadata resolution

- match relative /storage/ references and trim a trailing slash from serverURL
  so local resolution survives a serverURL change/misconfig. This lives in
  objectstorage (Client.LocalName / Client.LocalURL) so URL parse+format stay
  with the storage layer, not the api package.
- persist the promoted reference with a targeted SetProcessMetadataURL ($set
  metadataURL only) instead of a full SetProcess rewrite, so the read path can't
  clobber concurrent updates to other process fields.
- add tests: fetchExternalMetadata (ok/non-200/over-cap) and the remote->local
  cache+promot... (continued)

91 of 119 new or added lines in 4 files covered. (76.47%)

9909 of 15930 relevant lines covered (62.2%)

44.47 hits per line

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

53.03
/api/process.go
1
package api
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "io"
7
        "net/http"
8
        "strings"
9
        "time"
10

11
        "github.com/ethereum/go-ethereum/common"
12
        "github.com/go-chi/chi/v5"
13
        "github.com/vocdoni/saas-backend/account"
14
        "github.com/vocdoni/saas-backend/api/apicommon"
15
        "github.com/vocdoni/saas-backend/db"
16
        "github.com/vocdoni/saas-backend/errors"
17
        "github.com/vocdoni/saas-backend/internal"
18
        "github.com/vocdoni/saas-backend/subscriptions"
19
        "go.mongodb.org/mongo-driver/bson/primitive"
20
        dvoteapi "go.vocdoni.io/dvote/api"
21
        "go.vocdoni.io/dvote/log"
22
        "go.vocdoni.io/proto/build/go/models"
23
)
24

25
// createProcessHandler godoc
26
//
27
//        @Summary                Create a new voting process
28
//        @Description        Create a new voting process (draft). Requires Manager/Admin role of the owning organization. The
29
//        @Description        request must reference the organization via either a `censusId` or an explicit `orgAddress`.
30
//        @Description        Creating a draft consumes the plan's draft permission.
31
//        @Description
32
//        @Description        The optional `electionParams` field carries the on-chain election definition (questions, dates,
33
//        @Description        vote/election types) used later to publish the draft on chain via POST /process/{processId}/publish.
34
//        @Description        It is additive: omitting it keeps the legacy draft behavior.
35
//        @Description
36
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
37
//        @Tags                        process
38
//        @Accept                        json
39
//        @Produce                json
40
//        @Security                BearerAuth
41
//        @Param                        request        body                apicommon.CreateProcessRequest        true        "Process creation information (optionally with electionParams)"
42
//        @Success                200                {object}        primitive.ObjectID                                "24-hex draft ProcessID"
43
//        @Failure                400                {object}        errors.Error                                        "Invalid input data"
44
//        @Failure                401                {object}        errors.Error                                        "Unauthorized"
45
//        @Failure                403                {object}        errors.Error                                        "Plan does not allow creating more drafts"
46
//        @Failure                404                {object}        errors.Error                                        "Published census not found"
47
//        @Failure                409                {object}        errors.Error                                        "Process already exists"
48
//        @Failure                500                {object}        errors.Error                                        "Internal server error"
49
//        @Router                        /process [post]
50
func (a *API) createProcessHandler(w http.ResponseWriter, r *http.Request) {
15✔
51
        // parse the process info from the request body
15✔
52
        processInfo := &apicommon.CreateProcessRequest{}
15✔
53
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
15✔
54
                errors.ErrMalformedBody.Write(w)
×
55
                return
×
56
        }
×
57

58
        // get the user from the request context
59
        user, ok := apicommon.UserFromContext(r.Context())
15✔
60
        if !ok {
15✔
61
                errors.ErrUnauthorized.Write(w)
×
62
                return
×
63
        }
×
64

65
        // if it's a draft process
66
        if processInfo.Address.Equals(nil) && processInfo.OrgAddress == (common.Address{}) {
17✔
67
                errors.ErrMalformedBody.Withf("draft processes must provide an org address").Write(w)
2✔
68
                return
2✔
69
        }
2✔
70

71
        // Create or update the process
72
        process := &db.Process{
13✔
73
                Metadata:       processInfo.Metadata,
13✔
74
                ElectionParams: processInfo.ElectionParams,
13✔
75
        }
13✔
76

13✔
77
        var orgAddress common.Address
13✔
78
        if processInfo.CensusID != nil {
25✔
79
                var err error
12✔
80
                census, err := a.db.Census(processInfo.CensusID.String())
12✔
81
                if err != nil {
12✔
82
                        if err == db.ErrNotFound {
×
83
                                errors.ErrMalformedURLParam.Withf("invalid census provided").Write(w)
×
84
                                return
×
85
                        }
×
86
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
87
                        return
×
88
                }
89
                process.Census = *census
12✔
90
                orgAddress = census.OrgAddress
12✔
91
        } else if processInfo.OrgAddress != (common.Address{}) {
2✔
92
                orgAddress = processInfo.OrgAddress
1✔
93
        } else {
1✔
94
                errors.ErrMalformedBody.Withf("either census ID or organization address must be provided").Write(w)
×
95
                return
×
96
        }
×
97

98
        // check the user has the necessary permissions
99
        if !user.HasRoleFor(orgAddress, db.ManagerRole) && !user.HasRoleFor(orgAddress, db.AdminRole) {
13✔
100
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
101
                return
×
102
        }
×
103

104
        process.OrgAddress = orgAddress
13✔
105
        if !processInfo.Address.Equals(nil) {
15✔
106
                process.Address = processInfo.Address
2✔
107
        }
2✔
108

109
        // if it's a new draft process
110
        if process.Address.Equals(nil) && process.ID == primitive.NilObjectID {
24✔
111
                if err := a.subscriptions.OrgHasPermission(process.OrgAddress, subscriptions.CreateDraft); err != nil {
13✔
112
                        if apierr, ok := err.(errors.Error); ok {
4✔
113
                                apierr.Write(w)
2✔
114
                                return
2✔
115
                        }
2✔
116
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
117
                        return
×
118
                }
119
        }
120

121
        processID, err := a.db.SetProcess(process)
11✔
122
        if err != nil {
11✔
123
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
124
                return
×
125
        }
×
126

127
        apicommon.HTTPWriteJSON(w, processID)
11✔
128
}
129

130
// updateProcessHandler godoc
131
//
132
//        @Summary                Update an existing voting process
133
//        @Description        Update an existing draft voting process. Requires Manager/Admin role of the owning organization.
134
//        @Description        Only drafts that have not yet been published on chain can be updated; updating a published process
135
//        @Description        returns a 409. The optional `electionParams` field can be set or replaced here (additive), same as on
136
//        @Description        create.
137
//        @Tags                        process
138
//        @Accept                        json
139
//        @Produce                json
140
//        @Security                BearerAuth
141
//        @Param                        processId        path                string                                                        true        "24-hex draft ProcessID"
142
//        @Param                        request                body                apicommon.UpdateProcessRequest        true        "Process update information (optionally with electionParams)"
143
//        @Success                200                        {string}        string                                                        "OK"
144
//        @Failure                400                        {object}        errors.Error                                        "Invalid input data"
145
//        @Failure                401                        {object}        errors.Error                                        "Unauthorized"
146
//        @Failure                404                        {object}        errors.Error                                        "Process not found"
147
//        @Failure                409                        {object}        errors.Error                                        "Process already published and not in draft mode"
148
//        @Failure                500                        {object}        errors.Error                                        "Internal server error"
149
//        @Router                        /process/{processId} [put]
150
func (a *API) updateProcessHandler(w http.ResponseWriter, r *http.Request) {
2✔
151
        processID := chi.URLParam(r, "processId")
2✔
152
        if processID == "" {
2✔
153
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
154
                return
×
155
        }
×
156
        parsedID, err := primitive.ObjectIDFromHex(processID)
2✔
157
        if err != nil {
2✔
158
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
159
                return
×
160
        }
×
161

162
        // parse the process info from the request body
163
        processInfo := &apicommon.UpdateProcessRequest{}
2✔
164
        if err := json.NewDecoder(r.Body).Decode(&processInfo); err != nil {
2✔
165
                errors.ErrMalformedBody.Write(w)
×
166
                return
×
167
        }
×
168

169
        // get the user from the request context
170
        user, ok := apicommon.UserFromContext(r.Context())
2✔
171
        if !ok {
2✔
172
                errors.ErrUnauthorized.Write(w)
×
173
                return
×
174
        }
×
175

176
        existingProcess, err := a.db.Process(parsedID)
2✔
177
        if err != nil {
2✔
178
                if err == db.ErrNotFound {
×
179
                        errors.ErrProcessNotFound.Write(w)
×
180
                        return
×
181
                }
×
182
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
183
                return
×
184
        }
185

186
        // check if it's a draft process and can be overwritten
187
        if !existingProcess.Address.Equals(nil) {
3✔
188
                errors.ErrDuplicateConflict.Withf("process already exists and is not in draft mode").Write(w)
1✔
189
                return
1✔
190
        }
1✔
191

192
        // check the user has the necessary permissions
193
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
1✔
194
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
1✔
195
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
196
                return
×
197
        }
×
198

199
        var census *db.Census
1✔
200
        if !processInfo.CensusID.Equals(nil) {
1✔
201
                census, err = a.db.Census(processInfo.CensusID.String())
×
202
                if err != nil {
×
203
                        if err == db.ErrNotFound {
×
204
                                errors.ErrMalformedURLParam.Withf("census not found").Write(w)
×
205
                                return
×
206
                        }
×
207
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
208
                        return
×
209
                }
210
                existingProcess.Census = *census
×
211
        }
212

213
        if len(processInfo.Metadata) > 0 {
2✔
214
                existingProcess.Metadata = processInfo.Metadata
1✔
215
        }
1✔
216

217
        if processInfo.ElectionParams != nil {
1✔
218
                existingProcess.ElectionParams = processInfo.ElectionParams
×
219
        }
×
220

221
        if !processInfo.Address.Equals(nil) {
2✔
222
                existingProcess.Address = processInfo.Address
1✔
223
        }
1✔
224

225
        _, err = a.db.SetProcess(existingProcess)
1✔
226
        if err != nil {
1✔
227
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
228
                return
×
229
        }
×
230

231
        apicommon.HTTPWriteOK(w)
1✔
232
}
233

234
// processInfoHandler godoc
235
//
236
//        @Summary                Get process information
237
//        @Description        Retrieve voting process information by ID. Returns process details including census and metadata.
238
//        @Tags                        process
239
//        @Accept                        json
240
//        @Produce                json
241
//        @Param                        processId        path                string        true        "Process ID"
242
//        @Success                200                        {object}        apicommon.ProcessInfo
243
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
244
//        @Failure                404                        {object}        errors.Error        "Process not found"
245
//        @Failure                500                        {object}        errors.Error        "Internal server error"
246
//        @Router                        /process/{processId} [get]
247
func (a *API) processInfoHandler(w http.ResponseWriter, r *http.Request) {
15✔
248
        processID := chi.URLParam(r, "processId")
15✔
249
        if len(processID) == 0 {
15✔
250
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
251
                return
×
252
        }
×
253
        parsedID, err := primitive.ObjectIDFromHex(processID)
15✔
254
        if err != nil {
16✔
255
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
1✔
256
                return
1✔
257
        }
1✔
258

259
        process, err := a.db.Process(parsedID)
14✔
260
        if err != nil {
15✔
261
                if err == db.ErrNotFound {
2✔
262
                        errors.ErrProcessNotFound.Write(w)
1✔
263
                        return
1✔
264
                }
1✔
265
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
266
                return
×
267
        }
268

269
        // resolve the canonical metadata from its reference (best-effort): the Vochain only
270
        // resolves ipfs:// pointers, so the https /storage pointer this service publishes is
271
        // resolved here instead.
272
        if md := a.resolveProcessMetadata(r.Context(), process); md != nil {
16✔
273
                process.Metadata = md
3✔
274
        }
3✔
275

276
        apicommon.HTTPWriteJSON(w, &apicommon.ProcessInfo{
13✔
277
                Process: process,
13✔
278
                ChainID: a.account.ChainID(),
13✔
279
        })
13✔
280
}
281

282
// metadataObjectUserID is the object-storage owner recorded for server-generated
283
// metadata documents cached on read.
284
const metadataObjectUserID = "system"
285

286
// resolveProcessMetadata returns the canonical metadata for a published process,
287
// consulting process.MetadataURL. Returns nil (caller keeps the stored value) for
288
// drafts or on any error. Persists the reference inline only when it changed.
289
func (a *API) resolveProcessMetadata(ctx context.Context, p *db.Process) map[string]any {
13✔
290
        if p.Address.Equals(nil) { // draft, nothing on chain
21✔
291
                return nil
8✔
292
        }
8✔
293
        var election *dvoteapi.Election
5✔
294
        changed := false
5✔
295
        // bootstrap the reference from the on-chain pointer if we don't have one yet
5✔
296
        if p.MetadataURL == "" {
7✔
297
                el, err := a.account.Election(p.Address.Bytes())
2✔
298
                if err != nil {
4✔
299
                        log.Warnw("metadata: election fetch failed", "process", p.Address.String(), "error", err)
2✔
300
                        return nil
2✔
301
                }
2✔
NEW
302
                election, p.MetadataURL, changed = el, el.MetadataURL, true
×
303
        }
304

305
        // resolve the document. Branches set m on success and leave it nil on failure; no
306
        // early return, so a bootstrapped reference is still persisted below (avoids
307
        // re-deriving it from the chain on every read).
308
        var m map[string]any
3✔
309
        name, isLocal := a.objectStorage.LocalName(p.MetadataURL)
3✔
310
        switch {
3✔
311
        case isLocal:
2✔
312
                // our own object storage — resolve locally; treat a corrupt/non-JSON object as a
2✔
313
                // resolution failure (m stays nil) so the stored metadata is kept.
2✔
314
                if obj, err := a.objectStorage.GetByName(name); err == nil {
4✔
315
                        if json.Unmarshal(obj.Data, &m) != nil {
2✔
NEW
316
                                m = nil
×
NEW
317
                        }
×
318
                }
NEW
319
        case strings.HasPrefix(p.MetadataURL, "ipfs://"):
×
NEW
320
                // the Vochain resolves ipfs; reuse the election if already fetched
×
NEW
321
                if election == nil {
×
NEW
322
                        if el, err := a.account.Election(p.Address.Bytes()); err == nil {
×
NEW
323
                                election = el
×
NEW
324
                        }
×
325
                }
NEW
326
                if election != nil {
×
NEW
327
                        if mm, ok := election.Metadata.(map[string]any); ok && len(mm) > 0 {
×
NEW
328
                                m = mm
×
NEW
329
                        }
×
330
                }
331
        case strings.HasPrefix(p.MetadataURL, "http://"), strings.HasPrefix(p.MetadataURL, "https://"):
1✔
332
                m = fetchExternalMetadata(ctx, p.MetadataURL)
1✔
NEW
333
        default:
×
334
                // unrecognized scheme — leave m nil; a bootstrapped reference is still persisted below
335
        }
336

337
        // metadata is immutable (a change is a republish through this API), so cache any
338
        // remotely-resolved document locally and promote the reference — later reads then
339
        // resolve from our own storage with no Vochain or external round-trip.
340
        if m != nil && !isLocal {
4✔
341
                if b, err := json.Marshal(m); err == nil {
2✔
342
                        if stored, err := a.objectStorage.PutJSON(b, metadataObjectUserID); err == nil {
2✔
343
                                p.MetadataURL, changed = a.objectStorage.LocalURL(stored), true
1✔
344
                        }
1✔
345
                }
346
        }
347
        // persist a learned/promoted reference with a targeted update (not a full rewrite) so
348
        // a concurrent change to other process fields is not clobbered by this read path. This
349
        // runs even when resolution failed, so a bootstrapped reference is stored once.
350
        if changed {
4✔
351
                if err := a.db.SetProcessMetadataURL(p.ID, p.MetadataURL); err != nil {
1✔
NEW
352
                        log.Warnw("metadata: could not persist metadataURL", "process", p.Address.String(), "error", err)
×
NEW
353
                }
×
354
        }
355
        return m
3✔
356
}
357

358
// fetchExternalMetadata best-effort downloads and JSON-decodes a metadata document from
359
// an external http(s) reference (one that does not point at our own object storage).
360
// Returns nil on any failure.
361
func fetchExternalMetadata(ctx context.Context, url string) map[string]any {
4✔
362
        ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
4✔
363
        defer cancel()
4✔
364
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
4✔
365
        if err != nil {
4✔
NEW
366
                return nil
×
NEW
367
        }
×
368
        resp, err := http.DefaultClient.Do(req)
4✔
369
        if err != nil {
4✔
NEW
370
                return nil
×
NEW
371
        }
×
372
        defer func() { _ = resp.Body.Close() }()
8✔
373
        if resp.StatusCode != http.StatusOK {
5✔
374
                return nil
1✔
375
        }
1✔
376
        var m map[string]any
3✔
377
        if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m) != nil { // 1 MiB cap
4✔
378
                return nil
1✔
379
        }
1✔
380
        return m
2✔
381
}
382

383
// organizationListProcessDraftsHandler godoc
384
//
385
//        @Summary                Get paginated list of process drafts
386
//        @Description        Returns a list of voting process drafts.
387
//        @Tags                        process
388
//        @Accept                        json
389
//        @Produce                json
390
//        @Success                200        {object}        apicommon.ListOrganizationProcesses
391
//        @Failure                404        {object}        errors.Error        "Process not found"
392
//        @Failure                500        {object}        errors.Error        "Internal server error"
393
//        @Router                        /organizations/{address}/processes/drafts [get]
394
func (a *API) organizationListProcessDraftsHandler(w http.ResponseWriter, r *http.Request) {
4✔
395
        // get the organization info from the request context
4✔
396
        org, _, ok := a.organizationFromRequest(r)
4✔
397
        if !ok {
4✔
398
                errors.ErrNoOrganizationProvided.Write(w)
×
399
                return
×
400
        }
×
401
        // get the user from the request context
402
        user, ok := apicommon.UserFromContext(r.Context())
4✔
403
        if !ok {
4✔
404
                errors.ErrUnauthorized.Write(w)
×
405
                return
×
406
        }
×
407
        // check the user has the necessary permissions
408
        if !user.HasRoleFor(org.Address, db.ManagerRole) && !user.HasRoleFor(org.Address, db.AdminRole) {
4✔
409
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
410
                return
×
411
        }
×
412

413
        params, err := parsePaginationParams(r.URL.Query().Get(ParamPage), r.URL.Query().Get(ParamLimit))
4✔
414
        if err != nil {
4✔
415
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
416
                return
×
417
        }
×
418

419
        // retrieve the orgMembers with pagination
420
        totalItems, processes, err := a.db.ListProcesses(org.Address, params.Page, params.Limit, db.DraftOnly)
4✔
421
        if err != nil {
4✔
422
                errors.ErrGenericInternalServerError.Withf("could not get processes: %v", err).Write(w)
×
423
                return
×
424
        }
×
425
        pagination, err := calculatePagination(params.Page, params.Limit, totalItems)
4✔
426
        if err != nil {
4✔
427
                errors.ErrMalformedURLParam.WithErr(err).Write(w)
×
428
                return
×
429
        }
×
430

431
        apicommon.HTTPWriteJSON(w, &apicommon.ListOrganizationProcesses{
4✔
432
                Pagination: pagination,
4✔
433
                Processes:  processes,
4✔
434
        })
4✔
435
}
436

437
// deleteProcessHandler godoc
438
//
439
//        @Summary                Delete a voting process
440
//        @Description        Delete a voting process. Requires Manager/Admin role.
441
//        @Tags                        process
442
//        @Accept                        json
443
//        @Produce                json
444
//        @Security                BearerAuth
445
//        @Param                        processId        path                string                        true        "Process ID"
446
//        @Success                200                        {string}        string                        "OK"
447
//        @Failure                400                        {object}        errors.Error        "Invalid process ID"
448
//        @Failure                401                        {object}        errors.Error        "Unauthorized"
449
//        @Failure                404                        {object}        errors.Error        "Process not found"
450
//        @Failure                500                        {object}        errors.Error        "Internal server error"
451
//        @Router                        /process/{processId} [delete]
452
func (a *API) deleteProcessHandler(w http.ResponseWriter, r *http.Request) {
1✔
453
        processID := chi.URLParam(r, "processId")
1✔
454
        if processID == "" {
1✔
455
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
456
                return
×
457
        }
×
458
        parsedID, err := primitive.ObjectIDFromHex(processID)
1✔
459
        if err != nil {
1✔
460
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
461
                return
×
462
        }
×
463

464
        // get the user from the request context
465
        user, ok := apicommon.UserFromContext(r.Context())
1✔
466
        if !ok {
1✔
467
                errors.ErrUnauthorized.Write(w)
×
468
                return
×
469
        }
×
470

471
        existingProcess, err := a.db.Process(parsedID)
1✔
472
        if err != nil {
1✔
473
                if err == db.ErrNotFound {
×
474
                        errors.ErrProcessNotFound.Withf("process not found").Write(w)
×
475
                        return
×
476
                }
×
477
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
478
                return
×
479
        }
480

481
        // check the user has the necessary permissions
482
        if !user.HasRoleFor(existingProcess.OrgAddress, db.ManagerRole) &&
1✔
483
                !user.HasRoleFor(existingProcess.OrgAddress, db.AdminRole) {
1✔
484
                errors.ErrUnauthorized.Withf("user is not admin or manager of the organization that owns this process").Write(w)
×
485
                return
×
486
        }
×
487

488
        err = a.db.DelProcess(parsedID)
1✔
489
        if err != nil {
1✔
490
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
491
                return
×
492
        }
×
493

494
        apicommon.HTTPWriteOK(w)
1✔
495
}
496

497
// publishProcessHandler godoc
498
//
499
//        @Summary                Publish a draft process as an on-chain election
500
//        @Description        Publishes an existing draft process (created via POST /process) as an on-chain
501
//        @Description        election. The backend builds the election metadata and NewProcess transaction,
502
//        @Description        funds and signs it (with the organization signer) synchronously, then submits and
503
//        @Description        confirms it on a background worker; the call returns 202 with a job id. Poll GET
504
//        @Description        /jobs/{jobId} for the resulting on-chain id. Requires Admin role. Idempotent: if
505
//        @Description        the draft is already published its on-chain id is returned with 200 without sending
506
//        @Description        a new transaction. Publishing under a managed organization additionally enforces the
507
//        @Description        integrator's per-org and aggregate election/census quotas.
508
//        @Description
509
//        @Description        Also callable with a scoped API key (scope: `voting:write`).
510
//        @Tags                        process
511
//        @Accept                        json
512
//        @Produce                json
513
//        @Security                BearerAuth
514
//        @Param                        processId        path                string                                                                true        "Draft process ID"
515
//        @Success                202                        {object}        apicommon.EnqueuedResponse                        "Publish accepted; poll GET /jobs/{jobId}"
516
//        @Success                200                        {object}        apicommon.PublishProcessResponse        "Already published (idempotent): on-chain id and status"
517
//        @Failure                400                        {object}        errors.Error                                                "Invalid input data"
518
//        @Failure                401                        {object}        errors.Error                                                "Unauthorized"
519
//        @Failure                404                        {object}        errors.Error                                                "Process not found"
520
//        @Failure                409                        {object}        errors.Error                                                "A publish is already in progress for this draft"
521
//        @Failure                500                        {object}        errors.Error                                                "Internal server error"
522
//        @Failure                503                        {object}        errors.Error                                                "Transaction queue is full"
523
//        @Router                        /process/{processId}/publish [post]
524
func (a *API) publishProcessHandler(w http.ResponseWriter, r *http.Request) {
7✔
525
        processID := chi.URLParam(r, "processId")
7✔
526
        if processID == "" {
7✔
527
                errors.ErrMalformedURLParam.Withf("missing process ID").Write(w)
×
528
                return
×
529
        }
×
530
        parsedID, err := primitive.ObjectIDFromHex(processID)
7✔
531
        if err != nil {
7✔
532
                errors.ErrMalformedURLParam.Withf("invalid process ID").Write(w)
×
533
                return
×
534
        }
×
535

536
        user, ok := apicommon.UserFromContext(r.Context())
7✔
537
        if !ok {
7✔
538
                errors.ErrUnauthorized.Write(w)
×
539
                return
×
540
        }
×
541

542
        draft, err := a.db.Process(parsedID)
7✔
543
        if err != nil {
7✔
544
                if err == db.ErrNotFound {
×
545
                        errors.ErrProcessNotFound.Write(w)
×
546
                        return
×
547
                }
×
548
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
549
                return
×
550
        }
551

552
        // permission: Admin of the owning organization. Publishing maps to a NEW_PROCESS
553
        // tx, which subscriptions.HasTxPermission requires Admin for, so we enforce the
554
        // same role here rather than letting a Manager pass and be rejected downstream.
555
        if !user.HasRoleFor(draft.OrgAddress, db.AdminRole) {
7✔
556
                errors.ErrUnauthorized.Withf("user is not admin of the organization that owns this process").Write(w)
×
557
                return
×
558
        }
×
559

560
        // idempotent: already published
561
        if !draft.Address.Equals(nil) {
8✔
562
                apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: draft.Address, Status: draft.Status})
1✔
563
                return
1✔
564
        }
1✔
565

566
        // a draft must carry the high-level election definition to be publishable.
567
        // the on-chain census root is always the CSP public key, so the draft census
568
        // (member list used later by the CSP bundle flow) is not required here.
569
        if draft.ElectionParams == nil {
6✔
570
                errors.ErrMalformedBody.Withf("draft has no election params").Write(w)
×
571
                return
×
572
        }
×
573

574
        // atomically claim the draft for publishing BEFORE any expensive metadata/funding/
575
        // signing work. This single conditional update is the authoritative duplicate-publish
576
        // guard: two concurrent publishes cannot both win the claim, so only one NEW_PROCESS
577
        // tx is ever built for a draft.
578
        claimed, err := a.db.ClaimProcessForPublish(parsedID)
6✔
579
        if err != nil {
6✔
580
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
581
                return
×
582
        }
×
583
        if !claimed {
6✔
584
                // lost the claim: either another request is mid-publish, or it already finished.
×
585
                if cur, e := a.db.Process(parsedID); e == nil && !cur.Address.Equals(nil) {
×
586
                        apicommon.HTTPWriteJSON(w, &apicommon.PublishProcessResponse{Address: cur.Address, Status: cur.Status})
×
587
                        return
×
588
                }
×
589
                errors.ErrPublishInProgress.Write(w)
×
590
                return
×
591
        }
592

593
        // from here the draft is in PUBLISHING. Until a worker owns the job, every failure
594
        // path must release the claim, otherwise the draft is stuck (the release must $unset
595
        // status, since a zero-value string write is dropped by the dynamic update helper).
596
        committed := false
6✔
597
        defer func() {
12✔
598
                if committed {
11✔
599
                        return
5✔
600
                }
5✔
601
                if e := a.db.ClearProcessPublishing(parsedID); e != nil {
1✔
602
                        log.Warnw("could not clear publishing state after failed publish", "error", e)
×
603
                }
×
604
        }()
605

606
        org, err := a.db.Organization(draft.OrgAddress)
6✔
607
        if err != nil {
6✔
608
                if err == db.ErrNotFound {
×
609
                        errors.ErrOrganizationNotFound.Write(w)
×
610
                        return
×
611
                }
×
612
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
613
                return
×
614
        }
615

616
        // if this org is managed by an integrator, atomically reserve the integrator's
617
        // aggregate process/census quota BEFORE building the on-chain tx, so concurrent
618
        // publishes cannot each pass a stale check and exceed the cap. The reservation is
619
        // rolled back (deferred) unless the publish commits. Test-sized elections are exempt
620
        // from the integrator quota, mirroring the per-org Processes counter exemption below.
621
        managedReserved := false
6✔
622
        var integratorAddr common.Address
6✔
623
        nonTestSized := draft.ElectionParams.MaxCensusSize > uint64(db.TestMaxCensusSize)
6✔
624
        if org.ManagedBy != (common.Address{}) && nonTestSized {
8✔
625
                integrator, err := a.db.Organization(org.ManagedBy)
2✔
626
                if err != nil {
2✔
627
                        errors.ErrGenericInternalServerError.Withf("could not get integrator organization: %v", err).Write(w)
×
628
                        return
×
629
                }
×
630
                limits, err := a.subscriptions.EffectiveIntegratorLimits(integrator)
2✔
631
                if err != nil {
2✔
632
                        if apiErr, ok := err.(errors.Error); ok {
×
633
                                apiErr.Write(w)
×
634
                                return
×
635
                        }
×
636
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
637
                        return
×
638
                }
639
                if err := a.db.ReserveManagedPublish(integrator.Address,
2✔
640
                        limits.MaxManagedProcesses, limits.MaxManagedCensusSize, int(draft.ElectionParams.MaxCensusSize)); err != nil {
3✔
641
                        if err == db.ErrManagedQuotaReached {
2✔
642
                                errors.ErrIntegratorQuotaExceeded.Write(w)
1✔
643
                                return
1✔
644
                        }
1✔
645
                        errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
646
                        return
×
647
                }
648
                managedReserved = true
1✔
649
                integratorAddr = integrator.Address
1✔
650
                // roll back the reservation if the publish is not handed to a worker (any
1✔
651
                // synchronous failure below, or a full queue). Once enqueued the worker owns
1✔
652
                // the reservation outcome and clears managedReserved.
1✔
653
                defer func() {
2✔
654
                        if !managedReserved {
2✔
655
                                return
1✔
656
                        }
1✔
657
                        if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
×
658
                                log.Warnw("could not roll back managed processes counter", "error", e)
×
659
                        }
×
660
                        if e := a.db.AddOrganizationManagedCensusSize(integratorAddr,
×
661
                                -int64(draft.ElectionParams.MaxCensusSize)); e != nil {
×
662
                                log.Warnw("could not roll back managed census counter", "error", e)
×
663
                        }
×
664
                }()
665
        }
666

667
        orgSigner, err := account.OrganizationSigner(a.secret, org.Creator, org.Nonce)
5✔
668
        if err != nil {
5✔
669
                errors.ErrGenericInternalServerError.Withf("could not restore organization signer: %v", err).Write(w)
×
670
                return
×
671
        }
×
672

673
        cspPubKey, err := a.csp.PubKey()
5✔
674
        if err != nil {
5✔
675
                errors.ErrGenericInternalServerError.Withf("could not get csp public key: %v", err).Write(w)
×
676
                return
×
677
        }
×
678

679
        // build + store the election metadata content-addressed; its public URL is the
680
        // on-chain metadata pointer (must be known before the tx, so it cannot contain
681
        // the not-yet-known process id).
682
        metaBytes, err := account.BuildElectionMetadata(draft.ElectionParams)
5✔
683
        if err != nil {
5✔
684
                errors.ErrMalformedBody.Withf("invalid election params: %v", err).Write(w)
×
685
                return
×
686
        }
×
687
        objectName, err := a.objectStorage.PutJSON(metaBytes, user.Email)
5✔
688
        if err != nil {
5✔
689
                errors.ErrInternalStorageError.WithErr(err).Write(w)
×
690
                return
×
691
        }
×
692
        // build the reference via the storage client so the URL format (and trailing-slash
693
        // handling) matches what the read path's local-reference match expects.
694
        metadataURL := a.objectStorage.LocalURL(objectName)
5✔
695
        // persist the reference so the read path resolves it locally without a chain round-trip.
5✔
696
        draft.MetadataURL = metadataURL
5✔
697

5✔
698
        // serialize build->sign->submit per organization so a concurrent publish or status
5✔
699
        // change for the same org cannot read the same account nonce and sign a conflicting
5✔
700
        // tx. The worker releases the lock after submit (held across the async hand-off);
5✔
701
        // every synchronous failure below releases it via the deferred unlock.
5✔
702
        orgLock := a.orgTxLocks.lock(org.Address)
5✔
703
        lockHeld := true
5✔
704
        defer func() {
10✔
705
                if lockHeld {
5✔
706
                        orgLock.Unlock()
×
707
                }
×
708
        }()
709

710
        // build the NewProcess tx (CSP census)
711
        tx, err := a.account.BuildNewProcessTx(&account.NewProcessParams{
5✔
712
                OrgAddress:  draft.OrgAddress,
5✔
713
                Params:      draft.ElectionParams,
5✔
714
                CensusRoot:  cspPubKey,
5✔
715
                CensusURI:   a.serverURL,
5✔
716
                MetadataURL: metadataURL,
5✔
717
        })
5✔
718
        if err != nil {
5✔
719
                errors.ErrMalformedBody.Withf("could not build election: %v", err).Write(w)
×
720
                return
×
721
        }
×
722

723
        // fund
724
        fundedTx, txType, err := a.account.FundTransaction(tx, orgSigner.Address())
5✔
725
        if err != nil {
5✔
726
                if apiErr, ok := err.(errors.Error); ok {
×
727
                        apiErr.Write(w)
×
728
                        return
×
729
                }
×
730
                errors.ErrVochainRequestFailed.WithErr(err).Write(w)
×
731
                return
×
732
        }
733
        if txType == nil || *txType != models.TxType_NEW_PROCESS {
5✔
734
                errors.ErrInvalidTxFormat.With("unexpected tx type for publish").Write(w)
×
735
                return
×
736
        }
×
737

738
        // quota / permission (same engine as the /transactions path)
739
        if hasPermission, err := a.subscriptions.HasTxPermission(fundedTx, *txType, org, user); !hasPermission || err != nil {
5✔
740
                errors.ErrUnauthorized.Withf("user does not have permission to publish: %v", err).Write(w)
×
741
                return
×
742
        }
×
743

744
        // sign with the organization signer
745
        stx, err := a.account.SignTransaction(fundedTx, orgSigner)
5✔
746
        if err != nil {
5✔
747
                errors.ErrGenericInternalServerError.Withf("could not sign election tx: %v", err).Write(w)
×
748
                return
×
749
        }
×
750

751
        jobID, err := apicommon.NewJobID()
5✔
752
        if err != nil {
5✔
753
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
754
                return
×
755
        }
×
756
        if err := a.db.CreateTxJob(jobID, db.JobTypePublishProcess, org.Address); err != nil {
5✔
757
                errors.ErrGenericInternalServerError.WithErr(err).Write(w)
×
758
                return
×
759
        }
×
760

761
        // submit + confirm on the worker pool. On success the draft becomes READY with its
762
        // on-chain id; on failure the PUBLISHING claim is released and any managed reservation
763
        // is rolled back. The idempotency guard keys off draft.Address, so a retry after
764
        // failure cannot create a duplicate election.
765
        reserved := managedReserved
5✔
766
        censusSize := int64(draft.ElectionParams.MaxCensusSize)
5✔
767
        if !a.enqueueTx(txTask{jobID: jobID, run: func() (*db.JobResult, error) {
10✔
768
                defer orgLock.Unlock()
5✔
769
                data, err := a.account.SubmitSignedTx(stx)
5✔
770
                if err != nil {
5✔
771
                        if e := a.db.ClearProcessPublishing(parsedID); e != nil {
×
772
                                log.Warnw("could not clear publishing state after failed publish", "error", e)
×
773
                        }
×
774
                        if reserved {
×
775
                                if e := a.db.AddOrganizationManagedProcesses(integratorAddr, -1); e != nil {
×
776
                                        log.Warnw("could not roll back managed processes counter", "error", e)
×
777
                                }
×
778
                                if e := a.db.AddOrganizationManagedCensusSize(integratorAddr, -censusSize); e != nil {
×
779
                                        log.Warnw("could not roll back managed census counter", "error", e)
×
780
                                }
×
781
                        }
782
                        return nil, err
×
783
                }
784
                draft.Address = internal.HexBytes(data)
5✔
785
                draft.Status = "READY"
5✔
786
                draft.PublishedAt = time.Now()
5✔
787
                if _, err := a.db.SetProcess(draft); err != nil {
5✔
788
                        return nil, err
×
789
                }
×
790
                // best-effort per-org Processes counter; only count non-test-sized elections.
791
                if nonTestSized {
9✔
792
                        if err := a.db.IncrementOrganizationProcessesCounter(org.Address); err != nil {
4✔
793
                                log.Warnw("could not update organization process counter", "error", err)
×
794
                        }
×
795
                }
796
                return &db.JobResult{Address: draft.Address, Status: "READY"}, nil
5✔
797
        }}) {
×
798
                // full queue: mark the job failed so it is not orphaned pending; the deferred
×
799
                // unlock, publishing-claim release and reservation rollback all fire on return.
×
800
                if e := a.db.SetJobStatus(jobID, db.JobStatusFailed, nil, "tx queue full"); e != nil {
×
801
                        log.Warnw("could not mark job failed after full queue", "error", e)
×
802
                }
×
803
                errors.ErrTxQueueFull.Write(w)
×
804
                return
×
805
        }
806
        // handed to a worker: it now owns the publish claim, reservation and org lock.
807
        committed = true
5✔
808
        managedReserved = false
5✔
809
        lockHeld = false
5✔
810

5✔
811
        apicommon.HTTPWriteJSONStatus(w, http.StatusAccepted, &apicommon.EnqueuedResponse{JobID: jobID})
5✔
812
}
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