An electronic voting system for guild meetings. Supports Single Transferable Vote (STV) with Droop quota and Plain Majority voting methods.
# 1. Clone and install
git clone https://github.com/fyysikkokilta/fk-vaalimasiina.git
cd fk-vaalimasiina
pnpm install
# 2. Configure environment
cp .env.example .env
# Edit .env — at minimum set DATABASE_URL, AUTH_SECRET, ADMIN_EMAILS, OAUTH_PROVIDERS + credentials
# 3. Set up database
pnpm db:migrate
# 4. Start development server
pnpm devSee docs/development.md for full development setup instructions.
| Guide | Description |
|---|---|
| Customization | Branding, colors, logo, translations |
| Deployment | Docker, CI/CD, production setup |
| OAuth Setup | Google, GitHub, Microsoft, custom providers |
| Development | Dev environment, testing, contributing |
The voting process operates as follows:
- Check-in — A member attends the meeting and is marked present by the secretary.
- Voting Setup — When voting begins, a list of member emails is entered into the system.
- Distributing Voting Links — Each member receives a unique voting link by email.
- Casting Votes — Members rank candidates in order of preference via their link. The system stores voter identity and ballot data in separate tables to ensure anonymity — after a vote is processed, it cannot be traced back to the voter.
- Ballot Confirmation — Each member receives a unique ballot ID after voting.
- Displaying Results — Once voting closes, results are calculated and shown.
- Auditing — Members can verify their vote using the ballot ID in an auditing view.
- Closing — The election is closed after results are reviewed. A new election can then be created.
The system uses the STV algorithm with the Droop quota:
Steps:
- Count first-preference votes
- Elect candidates reaching the quota
- Transfer surplus votes at a fractional transfer value
- Eliminate the lowest candidate if no quota is reached; redistribute their votes
- Repeat until all seats are filled
Tiebreaking uses a deterministic seeded shuffle based on the election UUID (same result on every run, but random per election). See src/algorithm/stvAlgorithm.ts for the implementation.
For Plain Majority elections, the top N candidates by first-preference vote count are elected.
The diagram below reflects the schema defined in src/db/schema.ts (Drizzle ORM, PostgreSQL).
erDiagram
elections {
uuid election_id PK
varchar title
varchar description
int seats
election_status status
voting_method voting_method
timestamp date
varchar csv_file_path
}
voters {
uuid voter_id PK
uuid election_id FK
varchar email
}
has_voted {
uuid has_voted_id PK
uuid voter_id FK
}
candidates {
uuid candidate_id PK
uuid election_id FK
varchar name
}
ballots {
uuid ballot_id PK
uuid election_id FK
}
votes {
uuid vote_id PK
uuid ballot_id FK
uuid candidate_id FK
int rank
}
elections ||--o{ voters : "has"
elections ||--o{ candidates : "has"
elections ||--o{ ballots : "has"
voters ||--o| has_voted : "has voted"
ballots ||--o{ votes : "contains"
candidates ||--o{ votes : "receives"