11 / WORK / CASE STUDY
Shareholder AGM E-Voting System
An e-voting platform for shareholder AGMs — OTP login, meeting and agenda management, election-type motions with recusal rules, and a SHA-256 hash-chained audit log. Built solo in nine days; currently in acceptance and remediation.
- TYPE
- FULL STACK
- SCOPE
- Internal · acceptance stage
- STACK
- DJANGO · DRF · MYSQL · REACT · TYPESCRIPT · JWT
- LINKS
- Internal project (private)
01 / PROBLEM
Shareholder AGMs at small and mid-sized Taiwanese companies still run on paper ballots and manual counting: slow, hard to trace, and with no auditable record. This system lets shareholders log in quickly via OTP, operate under role-scoped permissions (shareholder, admin, guest, staff), and vote on both ordinary motions and election-type motions with statutory recusal rules. Design priorities are compliance-shaped — PII protection and audit-chain integrity — rather than large-scale concurrency.
02 / CONSTRAINTS
- A company-internal commissioned project; the codebase is private.
- Solo and compressed: 11 commits over nine days, eight of them landing security and performance hardening on the final day.
- PII protection is non-negotiable (mapped to ISO 27001 controls): ID and phone digits are stored only as hashes, and audit-log IPs and user agents must be masked.
- No Redis, Celery or other external infrastructure; OTP delivery is currently console-simulated, not yet wired to a real SMS/email service — recorded as-is.
03 / ARCHITECTURE
A Django 5.2 + DRF monolith: five apps (accounts, meetings, voting, otp_module, audits), 11 data models, five resource routes plus 21 custom actions — 51 measured API endpoints — with a React 19 + TypeScript + MUI frontend of 11 routed pages (~4,500 lines). Deliberately not microservices: one developer, one company’s AGM as the workload, and voting plus its audit chain need a single transaction boundary — splitting adds operations cost and nothing else.
Auth is JWT (with token blacklisting) plus OTP as a second factor, and Keycloak SSO integration (separate staff and shareholder realms, toggled by environment); authorization flows through three custom permission classes. Four core models share a soft-delete mixin (restore and hard-delete supported); throttling is tiered per endpoint — OTP requests 5/min, verification 10/min, voting 20/min. CI is a four-stage GitHub Actions pipeline (backend lint → backend tests → frontend lint → frontend build); deployment is gunicorn behind Nginx.
04 / RESPONSIBILITIES
Independent development: all 11 commits are mine (2026/04/04–12), covering the Django backend, the React frontend, data models, the PII-protection and audit-chain design, query performance work and the CI pipeline.
05 / CHALLENGES → SOLUTIONS
CHALLENGE
The audit log must be tamper-evident, yet ballot content — who voted for what — is highly sensitive PII: storing the vote payload in the log would just create a second leak surface.
SOLUTION
Every log entry goes through a single create_log() gate: sensitive fields are masked before write (ID, phone, OTP and tokens become ***; ballot content keeps only motion type and a completed flag), IPs and user agents land masked, and severity is auto-classified by action. Each record then chains the previous record’s hash into its own SHA-256 — tampering with any historical entry breaks every link after it, detectably.
CHALLENGE
ID and phone digits were originally stored in plaintext as the login comparison key — a database leak would expose directly identifiable PII — and the tables already held live plaintext rows, so “wipe and reload” was not an option.
SOLUTION
Added hash_pii() (salt + SHA-256) and converted the existing plaintext columns in place via a Django data migration, with all comparisons moving to hash-vs-hash; the salt comes from an environment variable and a missing value throws instead of silently defaulting. The same pattern was reused for the stock-number field later — a repeatable PII-migration recipe.
CHALLENGE
Tallying an agenda originally took eight separate queries for counts and weighted shares — scaling linearly with agendas and shareholders, with no constraint or test on query volume at all.
SOLUTION
Replaced them with one Case/When conditional aggregate computing counts and weighted shares together, and added a performance test asserting query count ≤ 10 via CaptureQueriesContext — turning “did performance regress” into a condition CI enforces, not something users eventually feel.
CHALLENGE
Acceptance re-verification found 225 tests defined in source but only 215 collected — nine of them failing — and another 10 tests that had never run at all: the accounts/tests/ package lacked an __init__.py and collided with accounts/tests.py, so they were silently skipped since the day they were written.
SOLUTION
Diagnosis and root-cause attribution are done: the nine failures trace to three independent changes (a login-contract revision, log-message localization, and a random→secrets security refactor), none of which updated its tests; the collision is a structural flaw in test organization. Current state, recorded as-is: 206/215 passing, nine to fix, ten to bring back into collection — remediation tracked.
06 / SYSTEM FACTS
51
API endpoints measured
11
models across 5 apps
206/215
tests passing (9 to fix)
9
days, zero to acceptance
07 / LESSONS
A performance fix without a query-count assertion is unverified: after merging eight queries into one, the ≤10 assertion means anyone who reverts to per-option queries fails CI immediately. That test belonged in the original design, not retrofitted at optimization time.
“The tests are written” is neither “the tests are running” nor “the running tests still match the code”: ten tests never executed thanks to a missing __init__.py and a name collision, and seven more went stale when the login contract changed. Next time CI gets a defined-vs-collected count check, and contract changes carry a mandatory run of their tests.
NEXT
AI Development Workflow