Compare commits
100 Commits
f1a951ce81
...
v1.1
Author | SHA1 | Date | |
---|---|---|---|
f519b94392
|
|||
5a0da6560d
|
|||
6aed7cb7fa
|
|||
5ed16bdab3
|
|||
7939a6fde1
|
|||
e78d0a7276
|
|||
e544ea8426
|
|||
56adeaf1b8
|
|||
a72eead432
|
|||
6e0375c666
|
|||
ff2a90ed3d
|
|||
6d92358f79
|
|||
e7387be995
|
|||
a1af7a48b2
|
|||
5570dca6c9
|
|||
1b6fc173d3
|
|||
37b06cfb9e
|
|||
8792b05cca
|
|||
9a03a9cebd
|
|||
bddae031cc
|
|||
5668b1cd6a
|
|||
729fbecae6
|
|||
fde07b74fa
|
|||
f94e08fc7f
|
|||
5e4a089b89
|
|||
27d166dad6
|
|||
62562272f1
|
|||
410e8f39d3
|
|||
60d1bb382c
|
|||
afac0f8f91
|
|||
70a0cd7d3b
|
|||
f784eb474f
|
|||
96133f00de
|
|||
1428534dc3
|
|||
66cdd0086c
|
|||
c095e9ae0b
|
|||
13c2693bdb
|
|||
82aa5ef57d
|
|||
07ceec4db2
|
|||
e102d01bff
|
|||
c878b33649
|
|||
2847c53bca
|
|||
3c2d45083f
|
|||
88790bdec2
|
|||
f22710464b
|
|||
22a6593e3a
|
|||
26ae032fd6
|
|||
b9d6d25245
|
|||
e8ced857e0
|
|||
2f13d7a76a
|
|||
7ab27a947e
|
|||
d1225d3258
|
|||
ee847020f7
|
|||
c7cccf2ec1
|
|||
c3563878b7
|
|||
4f50aca3b6
|
|||
9cc85b9a47
|
|||
98421c8c06
|
|||
cad5cfe636
|
|||
86d7d0e881
|
|||
5927e9c855
|
|||
0b0ecaba9b
|
|||
b25e090a7b
|
|||
4fbf72d84d
|
|||
d77bef4bda
|
|||
bf3368b736
|
|||
49a1df06d2
|
|||
ca824726b4
|
|||
a9278366dd
|
|||
0229f78976
|
|||
9efe9a3537
|
|||
d251909ee7
|
|||
8d3ce36dd9
|
|||
7f82a402de
|
|||
f884e29ccd
|
|||
1554743a95
|
|||
bbbdb637b0
|
|||
d4fc6a54cf
|
|||
941e9a36fb
|
|||
53aa4ab375
|
|||
3abaeba76b
|
|||
14b7446d77
|
|||
225749f9b7
|
|||
ee21f00d3f
|
|||
40f11c75af
|
|||
c9b7a5796e
|
|||
57bd72506b
|
|||
7beadf1538
|
|||
3c4cfe8e99
|
|||
e5e85d494e
|
|||
4ce099621d
|
|||
3074d0de93
|
|||
e446d4f747
|
|||
195bc7d85e
|
|||
218f56c060
|
|||
b5a1bfc247
|
|||
4d73c122e3
|
|||
29fb5880e3
|
|||
9e96be5ff2
|
|||
c8413eaff8
|
35
.gitea/workflows/image-build.yaml
Normal file
35
.gitea/workflows/image-build.yaml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push Docker Image on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: code.dlmw.ch
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: code.dlmw.ch/dlmw/qv:${{ env.GITHUB_REF_NAME }}
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -26,4 +26,8 @@ go.work.sum
|
|||||||
|
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
# exclude built binary
|
||||||
|
web
|
||||||
|
dist/
|
||||||
|
43
.goreleaser.yaml
Normal file
43
.goreleaser.yaml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: qv
|
||||||
|
main: ./cmd/web
|
||||||
|
binary: qv
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
goamd64:
|
||||||
|
- v1
|
||||||
|
- v2
|
||||||
|
- v3
|
||||||
|
- v4
|
||||||
|
goarm64:
|
||||||
|
- v8.0
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: qv-archive
|
||||||
|
name_template: >-
|
||||||
|
{{ .ProjectName }}_
|
||||||
|
{{- .Version }}_
|
||||||
|
{{- .Os }}_
|
||||||
|
{{- .Arch }}_
|
||||||
|
{{- if eq .Arch "amd64" }}{{ .Amd64 }}
|
||||||
|
{{- else if eq .Arch "arm64" }}{{ .Arm64 }}
|
||||||
|
{{- end }}
|
||||||
|
formats: [ "tar.gz" ]
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
formats: [ "zip" ]
|
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
FROM golang:1.23-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /usr/src/qv
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go build -ldflags "-s -w" -v -o /usr/local/bin/qv ./cmd/web/
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
|
||||||
|
RUN mkdir /qv
|
||||||
|
ENV QV_DATABASE_PATH="/qv/qv.sqlite"
|
||||||
|
VOLUME /qv
|
||||||
|
|
||||||
|
COPY --from=build /usr/local/bin/qv /usr/local/bin/
|
||||||
|
|
||||||
|
ENTRYPOINT ["qv"]
|
10
Makefile
Normal file
10
Makefile
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: compile compile-snapshot clean
|
||||||
|
|
||||||
|
compile:
|
||||||
|
@goreleaser release --clean --skip=publish
|
||||||
|
|
||||||
|
compile-snapshot:
|
||||||
|
@goreleaser release --clean --snapshot --skip=publish
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -r dist/
|
@ -2,39 +2,372 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
api "code.dlmw.ch/dlmw/qv/internal"
|
api "code.dlmw.ch/dlmw/qv/internal"
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/mappers"
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
"code.dlmw.ch/dlmw/qv/internal/validator"
|
"code.dlmw.ch/dlmw/qv/internal/validator"
|
||||||
|
"code.dlmw.ch/dlmw/qv/ui"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (app *application) indexPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
content, err := ui.Files.ReadFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) createElectionPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
content, err := ui.Files.ReadFile("create-election.html")
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getElectionResultsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
content, err := ui.Files.ReadFile("election-results.html")
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getElectionPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
content, err := ui.Files.ReadFile("election.html")
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
type createElectionRequestWithValidator struct {
|
type createElectionRequestWithValidator struct {
|
||||||
api.CreateElectionRequest
|
api.CreateElectionRequest
|
||||||
validator.Validator
|
validator.Validator
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) createElection(w http.ResponseWriter, r *http.Request) {
|
func (r *createElectionRequestWithValidator) isValid() bool {
|
||||||
|
r.CheckField(validator.NotBlank(r.Name), "name", "must not be blank")
|
||||||
|
r.CheckField(validator.GreaterThan(r.Tokens, 0), "tokens", "must be greater than 0")
|
||||||
|
r.CheckField(validator.After(r.ExpiresAt, time.Now()), "expiresAt", "must expire in a future date")
|
||||||
|
r.CheckField(validator.GreaterThan(len(r.Choices), 1), "choices", "there must be more than 1 choice")
|
||||||
|
r.CheckField(validator.UniqueValues(r.Choices), "choices", "must not contain duplicate values")
|
||||||
|
|
||||||
|
for _, choice := range r.Choices {
|
||||||
|
r.CheckField(validator.NotBlank(choice), "choice", "must not be blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.AreVotersKnown {
|
||||||
|
r.CheckField(validator.LesserThan(r.MaxVoters, 101),
|
||||||
|
"maxVoters",
|
||||||
|
"cannot create a known-voters election with more than 100 voters",
|
||||||
|
)
|
||||||
|
r.CheckField(
|
||||||
|
validator.GreaterThan(r.MaxVoters, 0),
|
||||||
|
"maxVoters",
|
||||||
|
"must be greater than 0 when voters are known",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
r.CheckField(
|
||||||
|
validator.GreaterThanOrEquals(r.MaxVoters, 0),
|
||||||
|
"maxVoters",
|
||||||
|
"must be a positive number",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) CreateElection(w http.ResponseWriter, r *http.Request) {
|
||||||
var request createElectionRequestWithValidator
|
var request createElectionRequestWithValidator
|
||||||
err := app.unmarshalRequest(r, &request)
|
|
||||||
if err != nil {
|
if err := app.unmarshalRequest(r, &request); err != nil {
|
||||||
app.clientError(w, http.StatusBadRequest, err.Error())
|
app.clientError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
request.CheckField(validator.NotBlank(request.Name), "name", "must not be blank")
|
if !request.isValid() {
|
||||||
request.CheckField(validator.GreaterThan(request.Tokens, 0), "tokens", "must be greater than 0")
|
|
||||||
request.CheckField(validator.After(request.ExpiresAt, time.Now()), "expiresAt", "must expire in a future date")
|
|
||||||
request.CheckField(validator.GreaterThan(len(request.Choices), 1), "choices", "there must be more than 1 choice")
|
|
||||||
|
|
||||||
request.CheckField(
|
|
||||||
!request.IsAnonymous || (request.IsAnonymous && *request.MaxVoters > 0),
|
|
||||||
"maxVoters",
|
|
||||||
"must be greater than 0 for anonymous elections",
|
|
||||||
)
|
|
||||||
|
|
||||||
if !request.Valid() {
|
|
||||||
app.unprocessableEntityError(w, request.Validator)
|
app.unprocessableEntityError(w, request.Validator)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Write([]byte("TODO"))
|
electionId, err := app.elections.Insert(
|
||||||
|
request.Name,
|
||||||
|
request.Tokens,
|
||||||
|
request.AreVotersKnown,
|
||||||
|
request.MaxVoters,
|
||||||
|
request.Choices,
|
||||||
|
request.ExpiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []byte
|
||||||
|
if request.AreVotersKnown {
|
||||||
|
voterIdentities := make([]string, 0, request.MaxVoters)
|
||||||
|
for i := 0; i < request.MaxVoters; i++ {
|
||||||
|
randomIdentity := randomVoterIdentity()
|
||||||
|
voterIdentities = append(voterIdentities, randomIdentity)
|
||||||
|
}
|
||||||
|
_, err = app.voters.InsertMultiple(voterIdentities, electionId)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = json.Marshal(api.CreateElectionResponse{VoterIdentities: &voterIdentities})
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Location", fmt.Sprintf("/election/%v", electionId))
|
||||||
|
w.Write(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomVoterIdentity() string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, 16)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[rand.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createVotesRequestWithValidator struct {
|
||||||
|
api.CreateVotesRequest
|
||||||
|
validator.Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *createVotesRequestWithValidator) isValid() bool {
|
||||||
|
for _, choice := range r.Choices {
|
||||||
|
r.CheckField(validator.NotBlank(choice.ChoiceText), "choiceText", "must not be blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) CreateVotes(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
var request createVotesRequestWithValidator
|
||||||
|
|
||||||
|
if err := app.unmarshalRequest(r, &request); err != nil {
|
||||||
|
app.clientError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.isValid() {
|
||||||
|
app.unprocessableEntityError(w, request.Validator)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
election, err := app.elections.GetById(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
app.clientError(w, http.StatusNotFound, "Couldn't find an election with the ID you provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range request.Choices {
|
||||||
|
choiceExists := slices.Contains(election.Choices, c.ChoiceText)
|
||||||
|
if !choiceExists {
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, fmt.Sprintf("choice %v doesn't exist", c.ChoiceText))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokensUsed := 0
|
||||||
|
for _, c := range request.Choices {
|
||||||
|
tokensUsed += c.Tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokensUsed > election.Tokens {
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, fmt.Sprintf("you used too many tokens; must not exceed %v tokens", election.Tokens))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
electionHasExpired := election.ExpiresAt.Before(time.Now())
|
||||||
|
if electionHasExpired {
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, "election has expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var voterIdentity string
|
||||||
|
if election.AreVotersKnown {
|
||||||
|
voterIdentity, err = app.createVotesHandleKnownVotersElection(w, r, request, election)
|
||||||
|
if err != nil {
|
||||||
|
// everything will have been logged already
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
voterIdentity, err = app.createVotesHandleUnknownVotersElection(w, r, election)
|
||||||
|
if err != nil {
|
||||||
|
// everything will have been logged already
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range request.Choices {
|
||||||
|
_, err := app.votes.Insert(voterIdentity, election.ID, c.ChoiceText, c.Tokens)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) createVotesHandleKnownVotersElection(w http.ResponseWriter, r *http.Request, request createVotesRequestWithValidator, election *models.Election) (string, error) {
|
||||||
|
if request.VoterIdentity == nil || validator.Blank(*request.VoterIdentity) {
|
||||||
|
message := "election has known voters; you must provide an identity provided by the organizer"
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, message)
|
||||||
|
return "", fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
voterIdentity := *request.VoterIdentity
|
||||||
|
hasCastVotes, err := app.votes.Exists(voterIdentity, election.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if hasCastVotes {
|
||||||
|
message := "you already voted"
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, message)
|
||||||
|
return "", fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
voterExists, err := app.voters.Exists(voterIdentity, election.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !voterExists {
|
||||||
|
message := "invalid voter identity"
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, message)
|
||||||
|
return "", fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return voterIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) createVotesHandleUnknownVotersElection(w http.ResponseWriter, r *http.Request, election *models.Election) (string, error) {
|
||||||
|
voterIdentity := realIP(r)
|
||||||
|
|
||||||
|
voterExists, err := app.voters.Exists(voterIdentity, election.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if voterExists {
|
||||||
|
message := "you already voted"
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, message)
|
||||||
|
return "", fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
voterCount, err := app.voters.CountByElection(election.ID)
|
||||||
|
if err != nil && !errors.Is(sql.ErrNoRows, err) {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if voterCount != 0 && voterCount == election.MaxVoters {
|
||||||
|
message := "maximum voters reached"
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity, message)
|
||||||
|
return "", fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = app.voters.InsertMultiple([]string{voterIdentity}, election.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return voterIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) GetElectionResults(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
votes, err := app.votes.GetByElection(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(sql.ErrNoRows, err) {
|
||||||
|
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find votes for an election with id %v", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := getResultsFromVotes(votes)
|
||||||
|
|
||||||
|
response, err := json.Marshal(api.ElectionResultsResponse{Results: &results})
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResultsFromVotes(votes *[]models.Vote) []api.VotesForChoice {
|
||||||
|
votesForChoice := make(map[string]int)
|
||||||
|
for _, v := range *votes {
|
||||||
|
votesForChoice[v.ChoiceText] += int(math.Floor(math.Sqrt(float64(v.Tokens))))
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]api.VotesForChoice, 0)
|
||||||
|
for choice, votes := range votesForChoice {
|
||||||
|
result := api.VotesForChoice{
|
||||||
|
Choice: choice,
|
||||||
|
Votes: votes,
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) GetElection(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
election, err := app.elections.GetById(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(sql.ErrNoRows, err) {
|
||||||
|
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find election with id %v", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := json.Marshal(mappers.ElectionResponse(election))
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(response)
|
||||||
}
|
}
|
||||||
|
@ -2,53 +2,920 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
api "code.dlmw.ch/dlmw/qv/internal"
|
"code.dlmw.ch/dlmw/qv/internal"
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const baseUri = "/api/"
|
||||||
validCreateElectionRequest = api.CreateElectionRequest{
|
|
||||||
Choices: []string{"Gandhi", "Buddha"},
|
|
||||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
||||||
IsAnonymous: false,
|
|
||||||
MaxVoters: nil,
|
|
||||||
Name: "Guy of the year",
|
|
||||||
Tokens: 100,
|
|
||||||
} // TODO: try to find a way to generate test data
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateElection(t *testing.T) {
|
func TestCreateElection(t *testing.T) {
|
||||||
app := newTestApplication(t)
|
app := newTestApplication(t)
|
||||||
server := newTestServer(t, app.routes())
|
server := newTestServer(t, app.routes())
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(id.String(), nil)
|
||||||
|
|
||||||
|
path := baseUri + "election"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
urlPath string
|
urlPath string
|
||||||
|
body any
|
||||||
expectedCode int
|
expectedCode int
|
||||||
expectedBody string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Valid request",
|
name: "Valid request (small name, other language)",
|
||||||
urlPath: "/election",
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"宮本武蔵", "伊東一刀斎"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "強",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
expectedCode: http.StatusOK,
|
expectedCode: http.StatusOK,
|
||||||
expectedBody: "",
|
},
|
||||||
|
{
|
||||||
|
name: "Valid request (voters unknown with unlimited voters)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid request (with 3 choices)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha", "You"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid request (voters unknown with max voters)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 1000,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid request (voters known with max voters)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 10,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (not enough choices)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi"},
|
||||||
|
ExpiresAt: time.Unix(0, 0),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (expiresAt is not in the future)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Unix(0, 0),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (max voters must be greater than 0 for known elections)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Unix(0, 0),
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (blank name)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
ExpiresAt: time.Unix(0, 0),
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (choices are not unique)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Gandhi"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request (choices contain blank entries)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha", ""},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for known voters election (max voters greater than 100)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateElectionRequest{
|
||||||
|
Choices: []string{"Gandhi", "Buddha", ""},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 101,
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
requestBody, err := json.Marshal(validCreateElectionRequest)
|
requestBody, err := json.Marshal(tt.body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
code, _, body := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
|
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
|
||||||
|
assert.Equal(t, tt.expectedCode, code)
|
||||||
t.Logf("Code was %v and body %v", code, body)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateElection_KnownVoters(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
electionId, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(electionId.String(), nil)
|
||||||
|
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("InsertMultiple", mock.Anything, mock.Anything).
|
||||||
|
Return([]int{1}, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election"
|
||||||
|
requestBody := api.CreateElectionRequest{
|
||||||
|
Choices: []string{"宮本武蔵", "伊東一刀斎"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 100,
|
||||||
|
Name: "強",
|
||||||
|
Tokens: 100,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, body := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
|
||||||
|
var voterIdentities struct {
|
||||||
|
VoterIdentities []string
|
||||||
|
}
|
||||||
|
err = json.Unmarshal([]byte(body), &voterIdentities)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, voterIdentities.VoterIdentities, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateElection_ServerError(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(0, fmt.Errorf(""))
|
||||||
|
|
||||||
|
path := baseUri + "election"
|
||||||
|
requestBody := api.CreateElectionRequest{
|
||||||
|
Choices: []string{"宮本武蔵", "伊東一刀斎"},
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 0,
|
||||||
|
Name: "強",
|
||||||
|
Tokens: 100,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, 500, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_UnknownVotersElection(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return(&models.Election{
|
||||||
|
ID: id.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 100,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(false, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("InsertMultiple", mock.Anything, mock.Anything).
|
||||||
|
Return([]int{1}, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("CountByElection", mock.Anything).
|
||||||
|
Return(0, nil)
|
||||||
|
|
||||||
|
mockVotes := app.votes.(*mockVoteModel)
|
||||||
|
mockVotes.
|
||||||
|
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(1, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/votes"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urlPath string
|
||||||
|
body any
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid request for unknown voters election",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (too many tokens used)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 41},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (choice doesn't exist)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddh", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestBody, err := json.Marshal(tt.body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
|
||||||
|
assert.Equal(t, tt.expectedCode, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_KnownVotersElection(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return(&models.Election{
|
||||||
|
ID: id.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 100,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
EXISTING_VOTER_IDENTITY := "EXISTING_VOTER_IDENTITY"
|
||||||
|
NON_EXISTING_VOTER_IDENTITY := "NON_EXISTING_VOTER_IDENTITY"
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", EXISTING_VOTER_IDENTITY, mock.Anything).
|
||||||
|
Return(true, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", NON_EXISTING_VOTER_IDENTITY, mock.Anything).
|
||||||
|
Return(false, nil)
|
||||||
|
|
||||||
|
mockVotes := app.votes.(*mockVoteModel)
|
||||||
|
mockVotes.
|
||||||
|
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(1, nil)
|
||||||
|
mockVotes.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(false, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/votes"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urlPath string
|
||||||
|
body any
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid request for unknown voters election",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: &EXISTING_VOTER_IDENTITY,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (non-existing voter identity)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: &NON_EXISTING_VOTER_IDENTITY,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (no voter identity provided)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (too many tokens used)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 41},
|
||||||
|
},
|
||||||
|
VoterIdentity: &EXISTING_VOTER_IDENTITY,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (choice doesn't exist)",
|
||||||
|
urlPath: path,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddh", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: &EXISTING_VOTER_IDENTITY,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestBody, err := json.Marshal(tt.body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
|
||||||
|
assert.Equal(t, tt.expectedCode, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_NonExistingElection(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return((*models.Election)(nil), sql.ErrNoRows)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/votes"
|
||||||
|
requestBody := api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_NonUuidElectionID(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return((*models.Election)(nil), sql.ErrNoRows)
|
||||||
|
|
||||||
|
path := baseUri + "election/1a/votes"
|
||||||
|
requestBody := api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_AlreadyVoted(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
unknownVotersElectionId, _ := uuid.NewV7()
|
||||||
|
unknownVotersElection := models.Election{
|
||||||
|
ID: unknownVotersElectionId.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 10,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}
|
||||||
|
|
||||||
|
knownVotersElection := unknownVotersElection
|
||||||
|
knownVotersElectionId, _ := uuid.NewV7()
|
||||||
|
knownVotersElection.ID = knownVotersElectionId.String()
|
||||||
|
knownVotersElection.AreVotersKnown = true
|
||||||
|
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", knownVotersElectionId.String()).
|
||||||
|
Return(&knownVotersElection, nil)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", unknownVotersElectionId.String()).
|
||||||
|
Return(&unknownVotersElection, nil)
|
||||||
|
|
||||||
|
mockVotes := app.votes.(*mockVoteModel)
|
||||||
|
mockVotes.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(true, nil)
|
||||||
|
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(true, nil)
|
||||||
|
|
||||||
|
layout := baseUri + "election/%v/votes"
|
||||||
|
knownVotersElectionPath := fmt.Sprintf(layout, unknownVotersElectionId)
|
||||||
|
unknownVotersElectionPath := fmt.Sprintf(layout, knownVotersElectionId)
|
||||||
|
voterIdentity := "anything"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urlPath string
|
||||||
|
body any
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Invalid request for known voters election (already voted)",
|
||||||
|
urlPath: knownVotersElectionPath,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: &voterIdentity,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid request for unknown voters election (already voted)",
|
||||||
|
urlPath: unknownVotersElectionPath,
|
||||||
|
body: api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: &voterIdentity,
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestBody, err := json.Marshal(tt.body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
|
||||||
|
assert.Equal(t, tt.expectedCode, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_UnknownVotersElectionMaxVotersReached(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return(&models.Election{
|
||||||
|
ID: id.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 10,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(false, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("InsertMultiple", mock.Anything, mock.Anything).
|
||||||
|
Return([]int{1}, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("CountByElection", mock.Anything).
|
||||||
|
Return(10, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/votes"
|
||||||
|
requestBody := api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVotes_ExpiredElection(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return(&models.Election{
|
||||||
|
ID: id.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 10,
|
||||||
|
CreatedAt: time.UnixMilli(1),
|
||||||
|
ExpiresAt: time.UnixMilli(100000),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
mockVoters := app.voters.(*mockVoterModel)
|
||||||
|
mockVoters.
|
||||||
|
On("Exists", mock.Anything, mock.Anything).
|
||||||
|
Return(false, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("Insert", mock.Anything, mock.Anything).
|
||||||
|
Return(1, nil)
|
||||||
|
mockVoters.
|
||||||
|
On("CountByElection", mock.Anything).
|
||||||
|
Return(10, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/votes"
|
||||||
|
requestBody := api.CreateVotesRequest{
|
||||||
|
Choices: []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}{
|
||||||
|
{ChoiceText: "Gandhi", Tokens: 60},
|
||||||
|
{ChoiceText: "Buddha", Tokens: 40},
|
||||||
|
},
|
||||||
|
VoterIdentity: nil,
|
||||||
|
}
|
||||||
|
requestBodyJson, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetElectionResults(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
electionID, _ := uuid.NewV7()
|
||||||
|
votes := []models.Vote{
|
||||||
|
{
|
||||||
|
VoterIdentity: "Voter1",
|
||||||
|
ElectionID: electionID.String(),
|
||||||
|
ChoiceText: "Choice1",
|
||||||
|
Tokens: 2,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VoterIdentity: "Voter2",
|
||||||
|
ElectionID: electionID.String(),
|
||||||
|
ChoiceText: "Choice2",
|
||||||
|
Tokens: 4,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VoterIdentity: "Voter3",
|
||||||
|
ElectionID: electionID.String(),
|
||||||
|
ChoiceText: "Choice3",
|
||||||
|
Tokens: 6,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VoterIdentity: "Voter4",
|
||||||
|
ElectionID: electionID.String(),
|
||||||
|
ChoiceText: "Choice1",
|
||||||
|
Tokens: 8,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VoterIdentity: "Voter5",
|
||||||
|
ElectionID: electionID.String(),
|
||||||
|
ChoiceText: "Choice2",
|
||||||
|
Tokens: 10,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockVotes := app.votes.(*mockVoteModel)
|
||||||
|
mockVotes.
|
||||||
|
On("GetByElection", electionID.String()).
|
||||||
|
Return(&votes, nil)
|
||||||
|
|
||||||
|
path := baseUri + fmt.Sprintf("election/%v/results", electionID)
|
||||||
|
code, _, body := server.get(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
|
||||||
|
var response api.ElectionResultsResponse
|
||||||
|
err := json.Unmarshal([]byte(body), &response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range *response.Results {
|
||||||
|
switch result.Choice {
|
||||||
|
case "Choice1":
|
||||||
|
assert.Equal(t, 3, result.Votes)
|
||||||
|
case "Choice2":
|
||||||
|
assert.Equal(t, 5, result.Votes)
|
||||||
|
case "Choice3":
|
||||||
|
assert.Equal(t, 2, result.Votes)
|
||||||
|
default:
|
||||||
|
t.Fatalf("Unexpected choice: %s", result.Choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetElectionResults_NotFound(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mockVotes := app.votes.(*mockVoteModel)
|
||||||
|
mockVotes.
|
||||||
|
On("GetByElection", mock.Anything).
|
||||||
|
Return(&[]models.Vote{}, sql.ErrNoRows)
|
||||||
|
|
||||||
|
path := baseUri + "election/1/results"
|
||||||
|
code, _, body := server.get(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
|
||||||
|
var response api.ElectionResultsResponse
|
||||||
|
err := json.Unmarshal([]byte(body), &response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetElection_Found(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
id, _ := uuid.NewV7()
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return(&models.Election{
|
||||||
|
ID: id.String(),
|
||||||
|
Name: "Guy of the year",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: false,
|
||||||
|
MaxVoters: 10,
|
||||||
|
CreatedAt: time.UnixMilli(1),
|
||||||
|
ExpiresAt: time.UnixMilli(100000),
|
||||||
|
Choices: []string{"Gandhi", "Buddha"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
path := baseUri + "election/1"
|
||||||
|
code, _, _ := server.get(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetElection_NotFound(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
server := newTestServer(t, app.routes())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
mockElections := app.elections.(*mockElectionModel)
|
||||||
|
mockElections.
|
||||||
|
On("GetById", mock.Anything).
|
||||||
|
Return((*models.Election)(nil), sql.ErrNoRows)
|
||||||
|
|
||||||
|
path := baseUri + "election/1"
|
||||||
|
code, _, _ := server.get(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
}
|
||||||
|
@ -5,10 +5,14 @@ import (
|
|||||||
"code.dlmw.ch/dlmw/qv/internal/validator"
|
"code.dlmw.ch/dlmw/qv/internal/validator"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
app.logger.Error(err.Error())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
var response = api.ErrorResponse{
|
var response = api.ErrorResponse{
|
||||||
Code: http.StatusInternalServerError,
|
Code: http.StatusInternalServerError,
|
||||||
@ -18,26 +22,36 @@ func (app *application) serverError(w http.ResponseWriter, r *http.Request, err
|
|||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *application) badRequestError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
var response = api.ErrorResponse{
|
||||||
|
Code: http.StatusBadRequest,
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *application) clientError(w http.ResponseWriter, status int, message string) {
|
func (app *application) clientError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
var response = api.ErrorResponse{
|
var response = api.ErrorResponse{
|
||||||
Code: http.StatusUnprocessableEntity,
|
Code: status,
|
||||||
Details: &map[string]interface{}{
|
Details: nil,
|
||||||
"error": message,
|
Message: message,
|
||||||
},
|
|
||||||
Message: "There was an error in the request",
|
|
||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) unprocessableEntityError(w http.ResponseWriter, v validator.Validator) {
|
func (app *application) unprocessableEntityError(w http.ResponseWriter, v validator.Validator) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
var response = api.ErrorResponse{
|
var response = api.ErrorResponse{
|
||||||
Code: http.StatusUnprocessableEntity,
|
Code: http.StatusUnprocessableEntity,
|
||||||
Details: &map[string]interface{}{
|
Details: &map[string]interface{}{
|
||||||
"fields": v.FieldErrors,
|
"fields": v.FieldErrors,
|
||||||
},
|
},
|
||||||
Message: "Election data is invalid",
|
Message: "Request data is invalid",
|
||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
@ -55,3 +69,23 @@ func (app *application) unmarshalRequest(r *http.Request, dst any) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func realIP(r *http.Request) string {
|
||||||
|
var ip string
|
||||||
|
|
||||||
|
if tcip := r.Header.Get(http.CanonicalHeaderKey("True-Client-IP")); tcip != "" {
|
||||||
|
ip = tcip
|
||||||
|
} else if xrip := r.Header.Get(http.CanonicalHeaderKey("X-Real-IP")); xrip != "" {
|
||||||
|
ip = xrip
|
||||||
|
} else if xff := r.Header.Get(http.CanonicalHeaderKey("X-Forwarded-For")); xff != "" {
|
||||||
|
i := strings.Index(xff, ",")
|
||||||
|
if i == -1 {
|
||||||
|
i = len(xff)
|
||||||
|
}
|
||||||
|
ip = xff[:i]
|
||||||
|
}
|
||||||
|
if ip == "" || net.ParseIP(ip) == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
@ -3,23 +3,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/migrations"
|
||||||
"code.dlmw.ch/dlmw/qv/internal/models"
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"github.com/go-playground/form/v4"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type application struct {
|
|
||||||
logger *slog.Logger
|
|
||||||
elections models.ElectionModelInterface
|
|
||||||
formDecoder *form.Decoder
|
|
||||||
}
|
|
||||||
|
|
||||||
var addr = ":8080"
|
var addr = ":8080"
|
||||||
|
var databasePath string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if os.Getenv("QV_DATABASE_PATH") == "" {
|
||||||
|
databasePath = "./qv.sqlite"
|
||||||
|
} else {
|
||||||
|
databasePath = os.Getenv("QV_DATABASE_PATH")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
@ -30,13 +36,17 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
err = migrations.Run(db)
|
||||||
formDecoder := form.NewDecoder()
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
app := &application{
|
app := &application{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
elections: &models.ElectionModel{DB: db},
|
elections: &models.ElectionModel{DB: db},
|
||||||
formDecoder: formDecoder,
|
voters: &models.VoterModel{DB: db},
|
||||||
|
votes: &models.VoteModel{DB: db},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Starting server", "addr", addr)
|
logger.Info("Starting server", "addr", addr)
|
||||||
@ -45,20 +55,38 @@ func main() {
|
|||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: app.routes(),
|
Handler: app.routes(),
|
||||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||||
IdleTimeout: time.Minute,
|
IdleTimeout: 30 * time.Second,
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = srv.ListenAndServe()
|
go watchForQuitSignals(srv, logger)
|
||||||
logger.Error(err.Error())
|
|
||||||
os.Exit(1)
|
if err = srv.ListenAndServe(); err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openDb() (*sql.DB, error) {
|
func openDb() (*sql.DB, error) {
|
||||||
db, err := sql.Open("sqlite3", "./qv.sqlite")
|
db, err := sql.Open("sqlite", databasePath+"?_foreign_keys=on&_busy_timeout=5000")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = db.Ping()
|
err = db.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, err
|
return db, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func watchForQuitSignals(srv *http.Server, logger *slog.Logger) {
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
s := <-quit
|
||||||
|
logger.Info("caught signal", "signal", s.String())
|
||||||
|
srv.Shutdown(ctx)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
@ -20,7 +20,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
|
|||||||
func (app *application) logRequest(next http.Handler) http.Handler {
|
func (app *application) logRequest(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ip = r.RemoteAddr
|
ip = realIP(r)
|
||||||
proto = r.Proto
|
proto = r.Proto
|
||||||
method = r.Method
|
method = r.Method
|
||||||
uri = r.URL.RequestURI()
|
uri = r.URL.RequestURI()
|
||||||
@ -29,3 +29,10 @@ func (app *application) logRequest(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *application) cacheStatic(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ info:
|
|||||||
title: qv - dlmw
|
title: qv - dlmw
|
||||||
description: |-
|
description: |-
|
||||||
This is the documentation for the qv (Quadratic Voting) API.
|
This is the documentation for the qv (Quadratic Voting) API.
|
||||||
termsOfService: http://swagger.io/terms/
|
termsOfService:
|
||||||
contact:
|
contact:
|
||||||
email: dylan@dlmw.ch
|
email: dylan@dlmw.ch
|
||||||
license:
|
license:
|
||||||
@ -11,15 +11,49 @@ info:
|
|||||||
url: https://www.gnu.org/licenses/gpl-3.0.txt
|
url: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
externalDocs:
|
externalDocs:
|
||||||
description: Find out more about qv # todo
|
description: Get the code
|
||||||
url: http://swagger.io # todo
|
url: https://code.dlmw.ch/dlmw/qv
|
||||||
servers:
|
servers:
|
||||||
- url: https://petstore3.swagger.io/api/v3 # todo
|
- url: https://qv.dlmw.ch/api
|
||||||
tags:
|
tags:
|
||||||
- name: election
|
- name: election
|
||||||
description: Retrieve data related to elections
|
description: Retrieve data related to elections
|
||||||
|
- name: vote
|
||||||
|
description: Retrieve data related to votes
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
/election/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- election
|
||||||
|
summary: Get an election
|
||||||
|
operationId: getElection
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The ID of the election
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Election returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Election"
|
||||||
|
400:
|
||||||
|
description: Request malformed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
404:
|
||||||
|
description: Election not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
/election:
|
/election:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -32,14 +66,114 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateElectionRequest"
|
$ref: "#/components/schemas/CreateElectionRequest"
|
||||||
responses:
|
responses:
|
||||||
201:
|
200:
|
||||||
description: Election created
|
description: Election created. Body only returned if voterAreKnown is true
|
||||||
|
headers:
|
||||||
|
Location:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: The path to the newly created election
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateElectionResponse"
|
||||||
422:
|
422:
|
||||||
description: Unprocessable Content
|
description: Unprocessable Content
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
500:
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/election/{id}/votes:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- vote
|
||||||
|
summary: Cast your votes for an election
|
||||||
|
operationId: createVotes
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The ID of the election
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateVotesRequest"
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Votes cast
|
||||||
|
400:
|
||||||
|
description: Request malformed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
404:
|
||||||
|
description: Election not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
422:
|
||||||
|
description: Unprocessable content
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
500:
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/election/{id}/results:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- election
|
||||||
|
summary: Get the results of an election
|
||||||
|
operationId: getElectionResults
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The ID of the election
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Election results returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ElectionResultsResponse"
|
||||||
|
400:
|
||||||
|
description: Request malformed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
404:
|
||||||
|
description: Election doesn't exist
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
500:
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
@ -49,124 +183,54 @@ components:
|
|||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
- tokens
|
- tokens
|
||||||
- is_anonymous
|
- areVotersKnown
|
||||||
- expires_at
|
- maxVoters
|
||||||
|
- createdAt
|
||||||
|
- expiresAt
|
||||||
|
- choices
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: string
|
||||||
format: int64
|
|
||||||
readOnly: true
|
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
|
||||||
tokens:
|
tokens:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
areVotersKnown:
|
||||||
is_anonymous:
|
|
||||||
type: boolean
|
type: boolean
|
||||||
max_voters:
|
maxVoters:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
createdAt:
|
||||||
nullable: true
|
|
||||||
description: Required when election is anonymous
|
|
||||||
created_at:
|
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
expiresAt:
|
||||||
readOnly: true
|
|
||||||
expires_at:
|
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
|
||||||
choices:
|
choices:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Choice'
|
type: string
|
||||||
voters:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Voter'
|
|
||||||
|
|
||||||
Choice:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- text
|
|
||||||
- election_id
|
|
||||||
properties:
|
|
||||||
text:
|
|
||||||
type: string
|
|
||||||
minLength: 1
|
|
||||||
election_id:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
|
|
||||||
Voter:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- identity
|
|
||||||
- election_id
|
|
||||||
properties:
|
|
||||||
identity:
|
|
||||||
type: string
|
|
||||||
minLength: 1
|
|
||||||
description: When election is anonymous, passcodes will be pre-generated
|
|
||||||
election_id:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
votes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Vote'
|
|
||||||
|
|
||||||
Vote:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- voter_identity
|
|
||||||
- election_id
|
|
||||||
properties:
|
|
||||||
voter_identity:
|
|
||||||
type: string
|
|
||||||
minLength: 1
|
|
||||||
election_id:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
choice_text:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
tokens:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
nullable: true
|
|
||||||
calculated_vote_count:
|
|
||||||
type: integer
|
|
||||||
readOnly: true
|
|
||||||
description: Calculated as floor(sqrt(tokens))
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
readOnly: true
|
|
||||||
|
|
||||||
CreateElectionRequest:
|
CreateElectionRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- tokens
|
- tokens
|
||||||
- is_anonymous
|
- areVotersKnown
|
||||||
- expires_at
|
- maxVoters
|
||||||
|
- expiresAt
|
||||||
- choices
|
- choices
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
tokens:
|
tokens:
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
is_anonymous:
|
|
||||||
type: boolean
|
|
||||||
max_voters:
|
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
nullable: true
|
areVotersKnown:
|
||||||
description: Required when election is anonymous
|
type: boolean
|
||||||
expires_at:
|
maxVoters:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Must be greater than 0 when voters are known; 0 = no limit
|
||||||
|
expiresAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
choices:
|
choices:
|
||||||
@ -177,6 +241,56 @@ components:
|
|||||||
minItems: 1
|
minItems: 1
|
||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
|
|
||||||
|
CreateElectionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
voterIdentities:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
uniqueItems: true
|
||||||
|
|
||||||
|
CreateVotesRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- choices
|
||||||
|
properties:
|
||||||
|
voterIdentity:
|
||||||
|
type: string
|
||||||
|
description: Must be filled if election has known voters
|
||||||
|
choices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
required:
|
||||||
|
- choiceText
|
||||||
|
- tokens
|
||||||
|
properties:
|
||||||
|
choiceText:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
tokens:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
ElectionResultsResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/VotesForChoice"
|
||||||
|
|
||||||
|
VotesForChoice:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- choice
|
||||||
|
- votes
|
||||||
|
properties:
|
||||||
|
choice:
|
||||||
|
type: string
|
||||||
|
votes:
|
||||||
|
type: integer
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@ -191,5 +305,4 @@ components:
|
|||||||
description: Machine-readable error code
|
description: Machine-readable error code
|
||||||
details:
|
details:
|
||||||
type: object
|
type: object
|
||||||
description: Additional error details when available
|
description: Additional error details when available
|
||||||
nullable: true
|
|
@ -1,14 +1,37 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
api "code.dlmw.ch/dlmw/qv/internal"
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
|
"code.dlmw.ch/dlmw/qv/ui"
|
||||||
"github.com/justinas/alice"
|
"github.com/justinas/alice"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type application struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
elections models.ElectionModelInterface
|
||||||
|
voters models.VoterModelInterface
|
||||||
|
votes models.VoteModelInterface
|
||||||
|
}
|
||||||
|
|
||||||
func (app *application) routes() http.Handler {
|
func (app *application) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("POST /election", app.createElection)
|
cached := alice.New(app.cacheStatic)
|
||||||
|
mux.Handle("GET /static/", cached.Then(http.FileServerFS(ui.Files)))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", app.indexPage)
|
||||||
|
mux.HandleFunc("GET /election/create", app.createElectionPage)
|
||||||
|
mux.HandleFunc("GET /election/{id}/results", app.getElectionResultsPage)
|
||||||
|
mux.HandleFunc("GET /election/{id}", app.getElectionPage)
|
||||||
|
|
||||||
|
api.HandlerWithOptions(app, api.StdHTTPServerOptions{
|
||||||
|
BaseRouter: mux,
|
||||||
|
BaseURL: "/api",
|
||||||
|
ErrorHandlerFunc: app.badRequestError,
|
||||||
|
})
|
||||||
|
|
||||||
standard := alice.New(app.recoverPanic, app.logRequest)
|
standard := alice.New(app.recoverPanic, app.logRequest)
|
||||||
return standard.Then(mux)
|
return standard.Then(mux)
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"code.dlmw.ch/dlmw/qv/internal/models"
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
"github.com/go-playground/form/v4"
|
"github.com/stretchr/testify/mock"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestApplication(t *testing.T) *application {
|
func newTestApplication(t *testing.T) *application {
|
||||||
formDecoder := form.NewDecoder()
|
|
||||||
|
|
||||||
return &application{
|
return &application{
|
||||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
elections: &models.ElectionModel{},
|
elections: &mockElectionModel{},
|
||||||
formDecoder: formDecoder,
|
voters: &mockVoterModel{},
|
||||||
|
votes: &mockVoteModel{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +30,22 @@ func newTestServer(t *testing.T, h http.Handler) *testServer {
|
|||||||
return &testServer{server}
|
return &testServer{server}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
|
||||||
|
rs, err := ts.Client().Get(ts.URL + urlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rs.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
|
||||||
|
return rs.StatusCode, rs.Header, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, http.Header, string) {
|
func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, http.Header, string) {
|
||||||
res, err := ts.Client().Post(ts.URL+urlPath, "application/json", body)
|
res, err := ts.Client().Post(ts.URL+urlPath, "application/json", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -43,3 +60,55 @@ func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, h
|
|||||||
|
|
||||||
return res.StatusCode, res.Header, string(responseBody)
|
return res.StatusCode, res.Header, string(responseBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockElectionModel struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *mockElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
|
||||||
|
args := e.Called(name, tokens, areVotersKnown, maxVoters, choices, expiresAt)
|
||||||
|
return args.String(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *mockElectionModel) GetById(id string) (*models.Election, error) {
|
||||||
|
args := e.Called(id)
|
||||||
|
return args.Get(0).(*models.Election), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockVoterModel struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
|
||||||
|
args := v.Called(identities, electionID)
|
||||||
|
return args.Get(0).([]int), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoterModel) CountByElection(electionID string) (int, error) {
|
||||||
|
args := v.Called(electionID)
|
||||||
|
return args.Int(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
|
||||||
|
args := v.Called(voterIdentity, electionID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockVoteModel struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
|
||||||
|
args := v.Called(voterIdentity, electionId, choiceText, tokens)
|
||||||
|
return args.Int(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
|
||||||
|
args := v.Called(voterIdentity, electionID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mockVoteModel) GetByElection(electionID string) (*[]models.Vote, error) {
|
||||||
|
args := v.Called(electionID)
|
||||||
|
return args.Get(0).(*[]models.Vote), args.Error(1)
|
||||||
|
}
|
||||||
|
37
go.mod
37
go.mod
@ -3,7 +3,40 @@ module code.dlmw.ch/dlmw/qv
|
|||||||
go 1.23.4
|
go 1.23.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-playground/form/v4 v4.2.1
|
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/justinas/alice v1.2.0
|
github.com/justinas/alice v1.2.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/oapi-codegen/runtime v1.1.1
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
modernc.org/sqlite v1.18.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
|
golang.org/x/mod v0.21.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
|
golang.org/x/tools v0.24.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
|
modernc.org/cc/v3 v3.36.3 // indirect
|
||||||
|
modernc.org/ccgo/v3 v3.16.9 // indirect
|
||||||
|
modernc.org/libc v1.17.1 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.2.1 // indirect
|
||||||
|
modernc.org/opt v0.1.3 // indirect
|
||||||
|
modernc.org/strutil v1.1.3 // indirect
|
||||||
|
modernc.org/token v1.0.0 // indirect
|
||||||
)
|
)
|
||||||
|
225
go.sum
225
go.sum
@ -1,178 +1,131 @@
|
|||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
|
||||||
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
|
|
||||||
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
|
||||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
|
||||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
|
||||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
|
||||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw=
|
|
||||||
github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
|
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
|
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
|
||||||
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
|
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
|
||||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
|
||||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
|
||||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
|
||||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
|
||||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
|
||||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
|
||||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
|
||||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
|
||||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||||
|
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
|
||||||
|
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||||
|
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
|
||||||
|
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
|
||||||
|
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||||
|
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
|
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||||
|
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||||
|
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||||
|
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
|
||||||
|
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
|
||||||
|
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||||
|
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
|
||||||
|
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
|
||||||
|
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
|
||||||
|
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||||
|
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
|
||||||
|
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
|
||||||
|
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
|
||||||
|
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
|
||||||
|
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||||
|
@ -9,40 +9,93 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/oapi-codegen/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateElectionRequest defines model for CreateElectionRequest.
|
// CreateElectionRequest defines model for CreateElectionRequest.
|
||||||
type CreateElectionRequest struct {
|
type CreateElectionRequest struct {
|
||||||
Choices []string `json:"choices"`
|
AreVotersKnown bool `json:"areVotersKnown"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
Choices []string `json:"choices"`
|
||||||
IsAnonymous bool `json:"is_anonymous"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
|
||||||
// MaxVoters Required when election is anonymous
|
// MaxVoters Must be greater than 0 when voters are known; 0 = no limit
|
||||||
MaxVoters *int `json:"max_voters"`
|
MaxVoters int `json:"maxVoters"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Tokens int `json:"tokens"`
|
Tokens int `json:"tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateElectionResponse defines model for CreateElectionResponse.
|
||||||
|
type CreateElectionResponse struct {
|
||||||
|
VoterIdentities *[]string `json:"voterIdentities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVotesRequest defines model for CreateVotesRequest.
|
||||||
|
type CreateVotesRequest struct {
|
||||||
|
Choices []struct {
|
||||||
|
ChoiceText string `json:"choiceText"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
} `json:"choices"`
|
||||||
|
|
||||||
|
// VoterIdentity Must be filled if election has known voters
|
||||||
|
VoterIdentity *string `json:"voterIdentity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Election defines model for Election.
|
||||||
|
type Election struct {
|
||||||
|
AreVotersKnown bool `json:"areVotersKnown"`
|
||||||
|
Choices []string `json:"choices"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
MaxVoters int `json:"maxVoters"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElectionResultsResponse defines model for ElectionResultsResponse.
|
||||||
|
type ElectionResultsResponse struct {
|
||||||
|
Results *[]VotesForChoice `json:"results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ErrorResponse defines model for ErrorResponse.
|
// ErrorResponse defines model for ErrorResponse.
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
// Code Machine-readable error code
|
// Code Machine-readable error code
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
|
|
||||||
// Details Additional error details when available
|
// Details Additional error details when available
|
||||||
Details *map[string]interface{} `json:"details"`
|
Details *map[string]interface{} `json:"details,omitempty"`
|
||||||
|
|
||||||
// Message Human-readable error message
|
// Message Human-readable error message
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VotesForChoice defines model for VotesForChoice.
|
||||||
|
type VotesForChoice struct {
|
||||||
|
Choice string `json:"choice"`
|
||||||
|
Votes int `json:"votes"`
|
||||||
|
}
|
||||||
|
|
||||||
// CreateElectionJSONRequestBody defines body for CreateElection for application/json ContentType.
|
// CreateElectionJSONRequestBody defines body for CreateElection for application/json ContentType.
|
||||||
type CreateElectionJSONRequestBody = CreateElectionRequest
|
type CreateElectionJSONRequestBody = CreateElectionRequest
|
||||||
|
|
||||||
|
// CreateVotesJSONRequestBody defines body for CreateVotes for application/json ContentType.
|
||||||
|
type CreateVotesJSONRequestBody = CreateVotesRequest
|
||||||
|
|
||||||
// ServerInterface represents all server handlers.
|
// ServerInterface represents all server handlers.
|
||||||
type ServerInterface interface {
|
type ServerInterface interface {
|
||||||
// Create a new election
|
// Create a new election
|
||||||
// (POST /election)
|
// (POST /election)
|
||||||
CreateElection(w http.ResponseWriter, r *http.Request)
|
CreateElection(w http.ResponseWriter, r *http.Request)
|
||||||
|
// Get an election
|
||||||
|
// (GET /election/{id})
|
||||||
|
GetElection(w http.ResponseWriter, r *http.Request, id string)
|
||||||
|
// Get the results of an election
|
||||||
|
// (GET /election/{id}/results)
|
||||||
|
GetElectionResults(w http.ResponseWriter, r *http.Request, id string)
|
||||||
|
// Cast your votes for an election
|
||||||
|
// (POST /election/{id}/votes)
|
||||||
|
CreateVotes(w http.ResponseWriter, r *http.Request, id string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerInterfaceWrapper converts contexts to parameters.
|
// ServerInterfaceWrapper converts contexts to parameters.
|
||||||
@ -69,6 +122,84 @@ func (siw *ServerInterfaceWrapper) CreateElection(w http.ResponseWriter, r *http
|
|||||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetElection operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) GetElection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.GetElection(w, r, id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetElectionResults operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) GetElectionResults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.GetElectionResults(w, r, id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVotes operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) CreateVotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.CreateVotes(w, r, id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
type UnescapedCookieParamError struct {
|
type UnescapedCookieParamError struct {
|
||||||
ParamName string
|
ParamName string
|
||||||
Err error
|
Err error
|
||||||
@ -184,6 +315,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.HandleFunc("POST "+options.BaseURL+"/election", wrapper.CreateElection)
|
m.HandleFunc("POST "+options.BaseURL+"/election", wrapper.CreateElection)
|
||||||
|
m.HandleFunc("GET "+options.BaseURL+"/election/{id}", wrapper.GetElection)
|
||||||
|
m.HandleFunc("GET "+options.BaseURL+"/election/{id}/results", wrapper.GetElectionResults)
|
||||||
|
m.HandleFunc("POST "+options.BaseURL+"/election/{id}/votes", wrapper.CreateVotes)
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
19
internal/mappers/elections.go
Normal file
19
internal/mappers/elections.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
api "code.dlmw.ch/dlmw/qv/internal"
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ElectionResponse(election *models.Election) *api.Election {
|
||||||
|
return &api.Election{
|
||||||
|
Id: election.ID,
|
||||||
|
Name: election.Name,
|
||||||
|
Tokens: election.Tokens,
|
||||||
|
AreVotersKnown: election.AreVotersKnown,
|
||||||
|
MaxVoters: election.MaxVoters,
|
||||||
|
CreatedAt: election.CreatedAt.String(),
|
||||||
|
ExpiresAt: election.ExpiresAt.String(),
|
||||||
|
Choices: election.Choices,
|
||||||
|
}
|
||||||
|
}
|
48
internal/mappers/elections_test.go
Normal file
48
internal/mappers/elections_test.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.dlmw.ch/dlmw/qv/internal/models"
|
||||||
|
uuid2 "github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestElectionResponse(t *testing.T) {
|
||||||
|
layout := "2006-01-02 15:04:05 -0700"
|
||||||
|
parsedCreatedAtTime, err := time.Parse(layout, "2025-01-14 15:13:37 +0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedExpiresAtTime, err := time.Parse(layout, "2025-01-15 15:13:37 +0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, _ := uuid2.NewV7()
|
||||||
|
election := models.Election{
|
||||||
|
ID: uuid.String(),
|
||||||
|
Name: "The best",
|
||||||
|
Tokens: 100,
|
||||||
|
AreVotersKnown: true,
|
||||||
|
MaxVoters: 150,
|
||||||
|
CreatedAt: parsedCreatedAtTime.In(time.UTC),
|
||||||
|
ExpiresAt: parsedExpiresAtTime.In(time.UTC),
|
||||||
|
Choices: []string{"You", "Me"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ElectionResponse(&election)
|
||||||
|
|
||||||
|
assert.Equal(t, uuid.String(), response.Id)
|
||||||
|
assert.Equal(t, "The best", response.Name)
|
||||||
|
assert.Equal(t, 100, response.Tokens)
|
||||||
|
assert.Equal(t, true, response.AreVotersKnown)
|
||||||
|
assert.Equal(t, 150, response.MaxVoters)
|
||||||
|
assert.Equal(t, "2025-01-14 15:13:37 +0000 UTC", response.CreatedAt)
|
||||||
|
assert.Equal(t, "2025-01-15 15:13:37 +0000 UTC", response.ExpiresAt)
|
||||||
|
|
||||||
|
for _, choice := range election.Choices {
|
||||||
|
assert.Contains(t, election.Choices, choice)
|
||||||
|
}
|
||||||
|
}
|
37
internal/migrations/migrations.go
Normal file
37
internal/migrations/migrations.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
msqlite3 "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed sql/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
func Run(db *sql.DB) error {
|
||||||
|
driver, err := msqlite3.WithInstance(db, &msqlite3.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := iofs.New(migrationsFS, "sql")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", d, "sqlite", driver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = m.Up(); err != nil && !errors.Is(migrate.ErrNoChange, err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -1,12 +1,12 @@
|
|||||||
CREATE TABLE elections (
|
CREATE TABLE elections (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tokens INTEGER NOT NULL,
|
tokens INTEGER NOT NULL,
|
||||||
is_anonymous INTEGER NOT NULL,
|
are_voters_known INTEGER NOT NULL,
|
||||||
max_voters INTEGER, -- mandatory when election is anonymous
|
max_voters INTEGER NOT NULL, -- must be greater than 0 when voters are known
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
CHECK (is_anonymous = 0 OR (is_anonymous = 1 AND max_voters IS NOT NULL AND max_voters >= 1))
|
CHECK (are_voters_known = 0 OR (are_voters_known = 1 AND max_voters IS NOT NULL AND max_voters >= 1))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TRIGGER prevent_created_at_update_election
|
CREATE TRIGGER prevent_created_at_update_election
|
||||||
@ -23,7 +23,7 @@ CREATE TABLE choices (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE voters (
|
CREATE TABLE voters (
|
||||||
identity TEXT NOT NULL, -- when election is anonymous, passcodes will be pre-generated
|
identity TEXT NOT NULL, -- when voters are known, passcodes will be pre-generated
|
||||||
election_id INTEGER NOT NULL,
|
election_id INTEGER NOT NULL,
|
||||||
PRIMARY KEY (identity, election_id),
|
PRIMARY KEY (identity, election_id),
|
||||||
FOREIGN KEY (election_id) REFERENCES elections (id)
|
FOREIGN KEY (election_id) REFERENCES elections (id)
|
||||||
@ -35,7 +35,7 @@ CREATE TRIGGER enforce_max_voters
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM elections e
|
FROM elections e
|
||||||
WHERE e.id = NEW.election_id
|
WHERE e.id = NEW.election_id
|
||||||
AND e.max_voters IS NOT NULL
|
AND e.max_voters != 0
|
||||||
)
|
)
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
@ -57,7 +57,6 @@ CREATE TABLE votes (
|
|||||||
election_id INTEGER NOT NULL,
|
election_id INTEGER NOT NULL,
|
||||||
choice_text TEXT NOT NULL,
|
choice_text TEXT NOT NULL,
|
||||||
tokens INTEGER NOT NULL,
|
tokens INTEGER NOT NULL,
|
||||||
calculated_vote_count GENERATED ALWAYS AS (floor(sqrt(tokens))) VIRTUAL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (voter_identity, choice_text),
|
PRIMARY KEY (voter_identity, choice_text),
|
||||||
FOREIGN KEY (voter_identity, election_id) REFERENCES voters (identity, election_id),
|
FOREIGN KEY (voter_identity, election_id) REFERENCES voters (identity, election_id),
|
||||||
@ -70,5 +69,3 @@ CREATE TRIGGER prevent_created_at_update_votes
|
|||||||
BEGIN
|
BEGIN
|
||||||
SELECT RAISE(FAIL, 'created_at column is read-only');
|
SELECT RAISE(FAIL, 'created_at column is read-only');
|
||||||
END;
|
END;
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON; -- run after opening the connection so foreign key constraints are checked
|
|
@ -2,18 +2,111 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"github.com/google/uuid"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ElectionModelInterface interface {
|
type ElectionModelInterface interface {
|
||||||
Insert(name string, tokens int, isAnonymous bool, maxVoters int, Choices []string, ExpiresAt time.Time) (int, error)
|
Insert(name string, tokens int, areVotersKnown bool, maxVoters int, Choices []string, ExpiresAt time.Time) (string, error)
|
||||||
|
GetById(id string) (*Election, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElectionModel struct {
|
type ElectionModel struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ElectionModel) Insert(name string, tokens int, isAnonymous bool, maxVoters int, Choices []string, ExpiresAt time.Time) (int, error) {
|
type Election struct {
|
||||||
//TODO implement me
|
ID string
|
||||||
panic("implement me")
|
Name string
|
||||||
|
Tokens int
|
||||||
|
AreVotersKnown bool
|
||||||
|
MaxVoters int
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Choices []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
|
||||||
|
tx, err := e.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
electionID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO elections (id, name, tokens, are_voters_known, max_voters, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`, electionID, name, tokens, areVotersKnown, maxVoters, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO choices (text, election_id)
|
||||||
|
VALUES (?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, choice := range choices {
|
||||||
|
_, err = stmt.Exec(choice, electionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return "0", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return electionID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ElectionModel) GetById(id string) (*Election, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, tokens, are_voters_known, max_voters, created_at, expires_at
|
||||||
|
FROM elections
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := e.DB.QueryRow(query, id)
|
||||||
|
|
||||||
|
election := &Election{}
|
||||||
|
|
||||||
|
err := row.Scan(&election.ID, &election.Name, &election.Tokens, &election.AreVotersKnown, &election.MaxVoters, &election.CreatedAt, &election.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve choices for the election
|
||||||
|
queryChoices := `
|
||||||
|
SELECT text
|
||||||
|
FROM choices
|
||||||
|
WHERE election_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := e.DB.Query(queryChoices, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var choice string
|
||||||
|
if err := rows.Scan(&choice); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
election.Choices = append(election.Choices, choice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return election, nil
|
||||||
}
|
}
|
||||||
|
104
internal/models/voters.go
Normal file
104
internal/models/voters.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VoterModelInterface interface {
|
||||||
|
InsertMultiple(identities []string, electionID string) ([]int, error)
|
||||||
|
CountByElection(electionID string) (int, error)
|
||||||
|
Exists(voterIdentity string, electionID string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoterModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
|
||||||
|
tx, err := v.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO voters (identity, election_id)
|
||||||
|
VALUES (?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
voterIDs := make([]int, 0, len(identities))
|
||||||
|
|
||||||
|
for _, identity := range identities {
|
||||||
|
result, err := stmt.Exec(identity, electionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
voterID, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
voterIDs = append(voterIDs, int(voterID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return voterIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoterModel) CountByElection(electionID string) (int, error) {
|
||||||
|
// use a transaction to prevent race conditions
|
||||||
|
tx, err := v.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(identity)
|
||||||
|
FROM voters
|
||||||
|
WHERE election_id = ?
|
||||||
|
GROUP BY election_id;
|
||||||
|
`
|
||||||
|
|
||||||
|
row := tx.QueryRow(query, electionID)
|
||||||
|
|
||||||
|
var voterCount int
|
||||||
|
|
||||||
|
err = row.Scan(&voterCount)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
return voterCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
|
||||||
|
query := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM voters
|
||||||
|
WHERE identity = ? AND election_id = ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := v.DB.QueryRow(query, voterIdentity, electionID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(sql.ErrNoRows, err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
107
internal/models/votes.go
Normal file
107
internal/models/votes.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VoteModelInterface interface {
|
||||||
|
Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error)
|
||||||
|
Exists(voterIdentity string, electionID string) (bool, error)
|
||||||
|
GetByElection(electionID string) (*[]Vote, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoteModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vote struct {
|
||||||
|
VoterIdentity string
|
||||||
|
ElectionID string
|
||||||
|
ChoiceText string
|
||||||
|
Tokens int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
|
||||||
|
tx, err := v.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
result, err := tx.Exec(`
|
||||||
|
INSERT INTO votes (voter_identity, election_id, choice_text, tokens)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
voterIdentity, electionId, choiceText, tokens)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
voteId, err := result.LastInsertId()
|
||||||
|
return int(voteId), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
query := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM votes
|
||||||
|
WHERE voter_identity = ? AND election_id = ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
err := v.DB.QueryRow(query, voterIdentity, electionID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(sql.ErrNoRows, err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoteModel) GetByElection(electionID string) (*[]Vote, error) {
|
||||||
|
query := `
|
||||||
|
SELECT voter_identity, election_id, choice_text, tokens, created_at
|
||||||
|
FROM votes
|
||||||
|
WHERE election_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := v.DB.Query(query, electionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var votes []Vote
|
||||||
|
for rows.Next() {
|
||||||
|
var vote Vote
|
||||||
|
err := rows.Scan(
|
||||||
|
&vote.VoterIdentity,
|
||||||
|
&vote.ElectionID,
|
||||||
|
&vote.ChoiceText,
|
||||||
|
&vote.Tokens,
|
||||||
|
&vote.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
votes = append(votes, vote)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(votes) == 0 {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return &votes, nil
|
||||||
|
}
|
@ -36,6 +36,10 @@ func (v *Validator) CheckField(ok bool, key, message string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Blank(value string) bool {
|
||||||
|
return strings.TrimSpace(value) == ""
|
||||||
|
}
|
||||||
|
|
||||||
func NotBlank(value string) bool {
|
func NotBlank(value string) bool {
|
||||||
return strings.TrimSpace(value) != ""
|
return strings.TrimSpace(value) != ""
|
||||||
}
|
}
|
||||||
@ -60,6 +64,27 @@ func GreaterThan(value int, n int) bool {
|
|||||||
return value > n
|
return value > n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LesserThan(value int, n int) bool {
|
||||||
|
return value < n
|
||||||
|
}
|
||||||
|
|
||||||
|
func GreaterThanOrEquals(value int, n int) bool {
|
||||||
|
return value >= n
|
||||||
|
}
|
||||||
|
|
||||||
func After(value time.Time, n time.Time) bool {
|
func After(value time.Time, n time.Time) bool {
|
||||||
return value.After(n)
|
return value.After(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UniqueValues[T comparable](values []T) bool {
|
||||||
|
seen := make(map[T]bool)
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
if seen[value] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
224
ui/create-election.html
Normal file
224
ui/create-election.html
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create New Election - qv</title>
|
||||||
|
<script src="/static/js/tailwind.min.js"></script>
|
||||||
|
<script src="/static/js/alpine.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
|
||||||
|
<div x-data="electionForm" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
|
||||||
|
<main>
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Create New Election</h1>
|
||||||
|
<form @submit.prevent="createElection" class="space-y-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700">Election Name</label>
|
||||||
|
<input type="text" id="name" x-model="election.name" required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="tokens" class="block text-sm font-medium text-gray-700">Tokens per Voter</label>
|
||||||
|
<input type="number" id="tokens" x-model.number="election.tokens" min="1" required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="areVotersKnown" class="block text-sm font-medium text-gray-700">Voter Access</label>
|
||||||
|
<select id="areVotersKnown" x-model="election.areVotersKnown" required
|
||||||
|
@change="election.areVotersKnown = JSON.parse(election.areVotersKnown)"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<option :value=true>Known voters only</option>
|
||||||
|
<option :value=false>Open to anyone</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Known voters only = codes will be generated for distribution.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="maxVoters" class="block text-sm font-medium text-gray-700">Maximum Number of Voters</label>
|
||||||
|
<input type="number" id="maxVoters" x-model.number="election.maxVoters"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<p class="text-sm text-gray-500">0 = unlimited</p>
|
||||||
|
<template x-if="election.areVotersKnown && election.maxVoters <= 0">
|
||||||
|
<span class="text-red-500 text-sm">Maximum number of voters must be greater than 0 if voters are known.</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="expiresAt" class="block text-sm font-medium text-gray-700">Expiration Date</label>
|
||||||
|
<input type="datetime-local" id="expiresAt" x-model="election.expiresAt" required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Choices</label>
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
<template x-for="(choice, index) in election.choices" :key="index">
|
||||||
|
<div class="flex space-x-2 items-center">
|
||||||
|
<input type="text" x-model="election.choices[index]" required
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<button type="button" class="text-red-500 hover:text-red-700"
|
||||||
|
@click="removeChoice(index)" x-show="election.choices.length > 2">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="addChoice"
|
||||||
|
class="mt-3 inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Add Another Choice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Create Election
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div x-show="errorMessage || Object.keys(errorDetails).length > 0" class="mt-6 bg-red-100 p-4 rounded-md">
|
||||||
|
<h2 class="text-red-700 font-bold" x-text="errorMessage"></h2>
|
||||||
|
<ul class="text-red-600 mt-2 space-y-1">
|
||||||
|
<template x-for="(message, field) in errorDetails" :key="field">
|
||||||
|
<li><strong x-text="field"></strong>: <span x-text="message"></span></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="createdElectionId != ''">
|
||||||
|
<div class="mt-6 bg-green-100 p-4 rounded-md">
|
||||||
|
<h2 class="text-green-700 font-bold">Election Created Successfully</h2>
|
||||||
|
<p class="mt-2">
|
||||||
|
<a
|
||||||
|
:href="`/election/${createdElectionId}`"
|
||||||
|
class="text-blue-600 underline hover:text-blue-800"
|
||||||
|
target="_blank">
|
||||||
|
View election
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
:href="`/election/${createdElectionId}/results`"
|
||||||
|
class="text-blue-600 underline hover:text-blue-800"
|
||||||
|
target="_blank">
|
||||||
|
View results
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="voterIdentities.length > 0">
|
||||||
|
<div class="voter-codes">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4">Voter Access Codes</h2>
|
||||||
|
<div class="codes-container">
|
||||||
|
<button @click="copyAllCodes" class="copy-all-btn bg-indigo-600 text-white py-2 px-4 rounded-md font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 mb-4">
|
||||||
|
Copy All Codes
|
||||||
|
</button>
|
||||||
|
<div class="codes-list">
|
||||||
|
<template x-for="(code, index) in voterIdentities" :key="index">
|
||||||
|
<div class="code-item flex items-center space-x-3 mb-2">
|
||||||
|
<span class="code-number text-gray-700 font-medium" x-text="index + 1"></span>.
|
||||||
|
<span class="code-text text-gray-900" x-text="code"></span>
|
||||||
|
<button @click="copyCode(code)" class="copy-btn bg-indigo-600 text-white text-sm py-1 px-3 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("electionForm", () => ({
|
||||||
|
election: {
|
||||||
|
name: "",
|
||||||
|
tokens: 100,
|
||||||
|
areVotersKnown: true,
|
||||||
|
maxVoters: 0,
|
||||||
|
expiresAt: "",
|
||||||
|
choices: ["", ""]
|
||||||
|
},
|
||||||
|
createdElectionId: "",
|
||||||
|
errorMessage: "",
|
||||||
|
errorDetails: {},
|
||||||
|
voterIdentities: [],
|
||||||
|
|
||||||
|
addChoice() {
|
||||||
|
this.election.choices.push("");
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChoice(index) {
|
||||||
|
this.election.choices.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createElection() {
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.errorDetails = {};
|
||||||
|
this.createdElectionId = "";
|
||||||
|
this.voterIdentities = [];
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...this.election,
|
||||||
|
expiresAt: this.election.expiresAt + ":00Z",
|
||||||
|
choices: this.election.choices.filter(choice => choice.trim() !== "")
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/election", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
this.errorMessage = errorData.message || "An error occurred.";
|
||||||
|
this.errorDetails = errorData.details?.fields || {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationHeader = response.headers.get("Location");
|
||||||
|
this.createdElectionId = locationHeader.replace("/election/", "");
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.voterIdentities = data.voterIdentities;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
this.errorMessage = "Failed to create election.";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyCode(code) {
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
alert("Code copied to clipboard!");
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to copy code: ", err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyAllCodes() {
|
||||||
|
const allCodes = this.voterIdentities.join("\n");
|
||||||
|
await navigator.clipboard.writeText(allCodes)
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Failed to copy all codes: ", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
145
ui/election-results.html
Normal file
145
ui/election-results.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Election Results - qv</title>
|
||||||
|
<script src="/static/js/tailwind.min.js"></script>
|
||||||
|
<script src="/static/js/chart.umd.js"></script>
|
||||||
|
<script src="/static/js/alpine.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
|
||||||
|
<div x-data="resultsPage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
|
||||||
|
<main>
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Election Results</h1>
|
||||||
|
|
||||||
|
<div x-show="error" class="bg-red-100 p-4 rounded-md text-red-700">
|
||||||
|
<p x-text="error"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="!error">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Chart Container -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Vote Distribution</h2>
|
||||||
|
<div class="h-96 w-full">
|
||||||
|
<canvas id="resultsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Results -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Detailed Results</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="(result, index) in results" :key="index">
|
||||||
|
<div class="flex justify-between border-b pb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: getColor(index) }"></div>
|
||||||
|
<span class="font-medium" x-text="result.choice"></span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
<span x-text="result.votes"></span> votes
|
||||||
|
(<span x-text="getPercentage(result.votes)"></span>%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('resultsPage', () => ({
|
||||||
|
results: [],
|
||||||
|
error: '',
|
||||||
|
chart: null,
|
||||||
|
colors: [
|
||||||
|
'#4f46e5', // Indigo (primary)
|
||||||
|
'#2563eb', // Blue
|
||||||
|
'#7c3aed', // Violet
|
||||||
|
'#db2777', // Pink
|
||||||
|
'#dc2626', // Red
|
||||||
|
],
|
||||||
|
|
||||||
|
getColor(index) {
|
||||||
|
return this.colors[index % this.colors.length];
|
||||||
|
},
|
||||||
|
|
||||||
|
getPercentage(votes) {
|
||||||
|
const total = this.results.reduce((sum, result) => sum + result.votes, 0);
|
||||||
|
if (total === 0) return '0.0';
|
||||||
|
return ((votes / total) * 100).toFixed(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.fetchResults();
|
||||||
|
this.initChart();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchResults() {
|
||||||
|
const electionId = window.location.pathname.split("/")[2];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/election/${electionId}/results`);
|
||||||
|
if (!response.ok) throw new Error("Failed to load election results.");
|
||||||
|
const data = await response.json();
|
||||||
|
this.results = data.results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initChart() {
|
||||||
|
if (this.error) return;
|
||||||
|
|
||||||
|
const ctx = document.getElementById('resultsChart').getContext('2d');
|
||||||
|
|
||||||
|
// Destroy existing chart if it exists
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: this.results.map(r => r.choice),
|
||||||
|
datasets: [{
|
||||||
|
data: this.results.map(r => r.votes),
|
||||||
|
backgroundColor: this.results.map((_, index) => this.getColor(index)),
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
boxWidth: 12,
|
||||||
|
boxHeight: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const total = context.dataset.data.reduce((sum, count) => sum + count, 0);
|
||||||
|
const percentage = ((context.raw / total) * 100).toFixed(1);
|
||||||
|
return `${context.raw} votes (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cutout: '60%'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
126
ui/election.html
Normal file
126
ui/election.html
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vote in Election - qv</title>
|
||||||
|
<script src="/static/js/tailwind.min.js"></script>
|
||||||
|
<script src="/static/js/alpine.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
|
||||||
|
<div x-data="votePage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
|
||||||
|
<main>
|
||||||
|
<h1 class="text-3xl font-bold mb-6" x-text="election.name || 'Loading...'"></h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitVote" class="space-y-6">
|
||||||
|
<!-- Code Field -->
|
||||||
|
<template x-if="election.areVotersKnown">
|
||||||
|
<div>
|
||||||
|
<label for="code" class="block text-sm font-medium text-gray-700">Access Code</label>
|
||||||
|
<input type="text" id="code" x-model="code" required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Voting Choices -->
|
||||||
|
<template x-if="election.choices.length > 0">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Distribute Your Tokens</label>
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
<template x-for="(choice, index) in election.choices" :key="index">
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700" x-text="choice"></label>
|
||||||
|
<input type="number" min="0"
|
||||||
|
x-model.number="votes[index]"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Tokens remaining: <span x-text="remainingTokens"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No Choices Message -->
|
||||||
|
<template x-if="!election.choices.length">
|
||||||
|
<p class="text-gray-700">No choices available for this election.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
:disabled="remainingTokens < 0"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50">
|
||||||
|
Submit Vote
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div x-show="message" class="mt-6 p-4 rounded-md" :class="{'bg-green-100 text-green-700': success, 'bg-red-100 text-red-700': !success}">
|
||||||
|
<p x-text="message"></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("votePage", () => ({
|
||||||
|
election: { name: "", choices: [], tokens: 0, areVotersKnown: false },
|
||||||
|
code: "",
|
||||||
|
votes: [],
|
||||||
|
message: "",
|
||||||
|
success: false,
|
||||||
|
|
||||||
|
get remainingTokens() {
|
||||||
|
return this.election.tokens - this.votes.reduce((sum, v) => sum + v, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const electionId = window.location.pathname.split("/").pop();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/election/${electionId}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to load election details.");
|
||||||
|
const data = await response.json();
|
||||||
|
this.election = data;
|
||||||
|
this.votes = Array(data.choices.length).fill(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.message = "Failed to load election details.";
|
||||||
|
this.success = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitVote() {
|
||||||
|
if (this.remainingTokens < 0) {
|
||||||
|
this.message = "You have exceeded the number of available tokens.";
|
||||||
|
this.success = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const electionId = window.location.pathname.split("/").pop();
|
||||||
|
const payload = {
|
||||||
|
voterIdentity: this.election.areVotersKnown ? this.code : null,
|
||||||
|
choices: this.election.choices.map((choice, index) => ({
|
||||||
|
choiceText: choice,
|
||||||
|
tokens: this.votes[index] || 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/election/${electionId}/votes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
res = await response.json();
|
||||||
|
throw new Error(res.message);
|
||||||
|
}
|
||||||
|
this.message = "Vote submitted successfully!";
|
||||||
|
this.success = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.message = error.message;
|
||||||
|
this.success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
28
ui/index.html
Normal file
28
ui/index.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>qv</title>
|
||||||
|
<script src="/static/js/tailwind.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 text-gray-900">
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center">
|
||||||
|
<header class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold mb-4">Quadratic Voting</h1>
|
||||||
|
<p class="text-lg text-gray-600">Start your journey by creating a new election.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="text-center">
|
||||||
|
<a href="/election/create"
|
||||||
|
class="bg-blue-600 text-white text-lg font-semibold px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||||
|
Create Election
|
||||||
|
</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="mt-12 text-center text-gray-600">
|
||||||
|
<a href="https://code.dlmw.ch/dlmw/qv" target="_blank">See the source code</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
6
ui/static/js/alpine.min.js
vendored
Normal file
6
ui/static/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
ui/static/js/chart.umd.js
Normal file
14
ui/static/js/chart.umd.js
Normal file
File diff suppressed because one or more lines are too long
84
ui/static/js/tailwind.min.js
vendored
Normal file
84
ui/static/js/tailwind.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user