Compare commits

..

8 Commits

38 changed files with 17965 additions and 260 deletions

2
.env
View File

@@ -1 +1 @@
db_path="./pta_vote.db"
db_path="pta_vote.db"

31
.gitignore vendored
View File

@@ -22,3 +22,34 @@
go.work
go.work.sum
# ---> React & npm
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# ---> NPM cache
.npmrc
# ---> Webpack
dist/
build/
# ---> Babel
.babelrc
babel.config.js
# ---> ESLint
.eslintrc
.eslintignore
eslintcache.js
# ---> Prettier
.prettierrc
prettierignore
# ---> Jest
coverage/
jest.config.js

23
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

16374
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
client/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"axios": "^1.13.2",
"cra-template-pwa": "2.0.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "7.12.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

40
client/public/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
client/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
client/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
client/public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

73
client/src/App.css Normal file
View File

@@ -0,0 +1,73 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dropdown {
display: inline-block;
}
.dropbtn {
background-color: #f5f5f5;
border: none;
cursor: pointer;
}
.dropdown-content {
display: none; /* Hidden by default */
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
}
.dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
/* Show the dropdown menu on hover */
.dropdown:hover .dropdown-content {
display: block;
}
/* Add zebra striping to tables */
table tr:nth-child(odd) {
background-color: #f5f5f5;
}

46
client/src/App.js Normal file
View File

@@ -0,0 +1,46 @@
import React from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router";
import Home from "./pages/Home";
import AdminLogin from "./pages/AdminLogin";
import AdminMembers from "./pages/AdminMembers";
import AdminMembersView from "./pages/AdminMembersView";
import AdminCreateVote from "./pages/AdminCreateVote";
import PollList from "./pages/PollList";
import './App.css';
export default function App() {
return (
<BrowserRouter>
<nav style={{ padding: "1rem", background: "#f5f5f5" }}>
<Link to="/">Home</Link> |
<Link to="/admin-login">Admin Login</Link> |
{/* Member dropdown */}
<div className="dropdown">
<button className="dropbtn">Member </button>
<div className="dropdown-content">
<a href="/admin-members">Upload Members</a>
<a href="/admin-members-view">View Members</a>
</div>
</div> |
<div className="dropdown">
<button className="dropbtn">Vote </button>
<div className="dropdown-content">
<a href="/create-vote">Create Vote</a>
<a href="/polls">Poll List</a>
</div>
</div>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin-login" element={<AdminLogin />} />
{/* Member routes */}
<Route path="/admin-members" element={<AdminMembers />} />
<Route path="/admin-members-view" element={<AdminMembersView />} />
{/* Vote routes */}
<Route path="/create-vote" element={<AdminCreateVote />} />
<Route path="/polls" element={<PollList />} />
</Routes>
</BrowserRouter>
);
}

9
client/src/App.test.js Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

13
client/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

7
client/src/index.js Normal file
View File

@@ -0,0 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css"; // optional keep CRA default styling
const container = document.getElementById("root");
createRoot(container).render(<App />);

7
client/src/logo.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
function AdminCreateVote() {
const [question, setQuestion] = useState('');
const [expiresInHours, setExpiresInHours] = useState('');
const [status, setStatus] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append("question", question);
formData.append("expiresInHours", expiresInHours);
try {
const resp = await fetch("/api/admin/new-vote", {
method: "POST",
body: formData,
});
const data = await resp.json();
if (data.success) {
setStatus(`✅ Vote created with ID`);
} else {
setStatus(`❌ Server error: ${data.error}`);
}
} catch (error) {
setStatus("❌ Failed to create vote. Please try again: " + (error.response?.data?.error || error.message));
}
};
return (
<div>
<h1>Create New Vote</h1>
<form onSubmit={handleSubmit}>
<div>
<label>Question:</label>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
</div>
<div>
<label>Expires In (hours):</label>
<input
type="number"
min="1"
value={expiresInHours}
onChange={(e) => setExpiresInHours(e.target.value)}
required
/>
</div>
<button type="submit">Create Vote</button>
</form>
{status && <p style={{ marginTop: "1rem" }}>{status}</p>}
</div>
);
}
export default AdminCreateVote;

View File

@@ -0,0 +1,137 @@
import React, { useState } from "react";
import { useNavigate } from "react-router";
export default function AdminLogin() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setIsLoading(true);
if (!username || !password) {
setError("⚠️ Please enter both username and password.");
setIsLoading(false);
return;
}
try {
const resp = await fetch("/api/admin/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
}),
});
const data = await resp.json();
if (resp.ok && data.success) {
// Store the auth token in localStorage
localStorage.setItem("adminToken", data.token);
setError("");
navigate("/admin-members");
} else {
setError(`${data.error || "Login failed"}`);
}
} catch (err) {
setError(`❌ Network error: ${err.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div
style={{
padding: "2rem",
maxWidth: "400px",
margin: "5rem auto",
border: "1px solid #ddd",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<h2 style={{ textAlign: "center", marginBottom: "2rem" }}>Admin Login</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
marginTop: "0.5rem",
border: "1px solid #ccc",
borderRadius: "4px",
boxSizing: "border-box",
}}
placeholder="Enter your username"
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
marginTop: "0.5rem",
border: "1px solid #ccc",
borderRadius: "4px",
boxSizing: "border-box",
}}
placeholder="Enter your password"
/>
</div>
{error && (
<div
style={{
padding: "1rem",
marginBottom: "1rem",
backgroundColor: "#ffe0e0",
border: "1px solid #ff6b6b",
borderRadius: "4px",
color: "#c92a2a",
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
style={{
width: "100%",
padding: "0.75rem",
backgroundColor: isLoading ? "#ccc" : "#007bff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "1rem",
fontWeight: "bold",
}}
>
{isLoading ? "Logging in..." : "Login"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from 'react-router';
export default function AdminMembers() {
const [year, setYear] = useState("");
const [file, setFile] = useState(null);
const [status, setStatus] = useState("");
const navigate = useNavigate();
const isAdmin = () => {
return localStorage.getItem('adminToken') !== null;
};
if (!isAdmin()) {
navigate('/admin-login');
return <div>Redirecting...</div>;
}
const handleSubmit = async (e) => {
e.preventDefault();
if (!year) {
setStatus("⚠️ Please enter a year.");
return;
}
if (!file) {
setStatus("⚠️ Please select a CSV file.");
return;
}
const formData = new FormData();
formData.append("year", year);
formData.append("members.csv", file); // name must match the Go handler
try {
const resp = await fetch("/api/admin/members", {
method: "POST",
body: formData,
});
const data = await resp.json();
if (data.success) {
setStatus(`✅ Uploaded!`);
} else {
setStatus(`❌ Server error: ${data.error}`);
}
} catch (err) {
setStatus(`❌ Network error: ${err.message}`);
}
};
return (
<div style={{ padding: "2rem" }}>
<h2>Upload Members CSV</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="year">Year:</label>
<input
type="number"
id="year"
name="year"
value={year}
onChange={(e) => setYear(e.target.value)}
required
min="1900"
max="2100"
style={{ width: "150px", padding: "0.3rem" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="members.csv">CSV File:</label>
<input
type="file"
id="members.csv"
name="members.csv"
accept=".csv"
onChange={(e) => setFile(e.target.files[0])}
required
/>
</div>
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
Upload
</button>
</form>
{status && <p style={{ marginTop: "1rem" }}>{status}</p>}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import React, { useState } from "react";
import { useNavigate } from 'react-router';
import Table from '@mui/material/Table';
export default function AdminMembersView() {
const [year, setYear] = useState("");
const [members, setMembers] = useState([]);
const [status, setStatus] = useState("");
const navigate = useNavigate();
const isAdmin = () => {
return localStorage.getItem('adminToken') !== null;
};
if (!isAdmin()) {
navigate('/admin-login');
return <div>Redirecting...</div>;
}
const handleSubmit = async (e) => {
e.preventDefault();
try {
const resp = await fetch(`/api/admin/members/view?year=${year}`);
const data = await resp.json();
if (data.success) {
setMembers(data.members);
} else {
setStatus(`❌ Server error: ${data.error}`);
}
} catch (err) {
setStatus(`❌ Network error: ${err.message}`);
}
};
return (
<div style={{ padding: "2rem" }}>
<h2>View Members</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="year">Year:</label>
<input
type="number"
id="year"
name="year"
value={year}
onChange={(e) => setYear(e.target.value)}
required
min="1900"
max="2100"
style={{ width: "150px", padding: "0.3rem" }}
/>
</div>
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
View Members
</button>
</form>
{members.length > 0 && (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{members.map((member, index) => (
<tr key={index}>
<td>{member.Name}</td>
<td>{member.Email}</td>
</tr>
))}
</tbody>
</Table>
)}
{status && <p style={{ marginTop: "1rem" }}>{status}</p>}
</div>
);
}

8
client/src/pages/Home.js Normal file
View File

@@ -0,0 +1,8 @@
export default function Home() {
return (
<div style={{ padding: "2rem" }}>
<h1>Welcome!</h1>
<p>This is the landing page. Use the navigation bar to go to the admin page.</p>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import Table from '@mui/material/Table';
export default function PollList() {
const [polls, setPolls] = useState([]);
useEffect(() => {
fetchPolls();
}, []);
const fetchPolls = async () => {
try {
const response = await axios.post('/api/admin/view-votes');
setPolls(response.data);
} catch (error) {
console.error('Error fetching polls:', error);
}
};
return (
<div>
<h1>Poll List</h1>
<Table>
<thead>
<tr>
<th>Created At</th>
<th>Question</th>
<th>Member Yes Votes</th>
<th>Member No Votes</th>
</tr>
</thead>
<tbody>
{polls.map((poll) => (
<tr key={poll.id}>
<td>{new Date(poll.created_at).toLocaleString()}</td>
<td>{poll.question}</td>
<td>{poll.member_yes}</td>
<td>{poll.member_no}</td>
</tr>
))}
</tbody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,72 @@
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
} // Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

View File

@@ -0,0 +1,137 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

5
client/src/setupTests.js Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.24.4
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/gorilla/mux v1.8.1
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1

2
go.sum
View File

@@ -4,6 +4,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=

11
server/common/common.go Normal file
View File

@@ -0,0 +1,11 @@
package common
import (
"encoding/json"
"net/http"
)
func SendError(w http.ResponseWriter, errStr string, statusCode int) {
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]string{"error": errStr})
}

View File

@@ -2,10 +2,7 @@ package db
import (
"database/sql"
"errors"
"log"
"os"
"strings"
"go-sjles-pta-vote/server/config"
@@ -43,11 +40,8 @@ CREATE TABLE IF NOT EXISTS members (
var db *sql.DB
func Connect() (*sql.DB, error) {
log.Printf("Connecting to database")
db_config := config.GetConfig()
log.Printf("Database path: %s", db_config.DBPath)
db, err := sql.Open("sqlite", db_config.DBPath)
if err != nil {
log.Printf("Error opening database: %v", err)

View File

@@ -2,39 +2,40 @@ package main
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"os"
"strconv"
"path/filepath"
"github.com/gorilla/mux"
"go-sjles-pta-vote/server/common"
"go-sjles-pta-vote/server/models"
"go-sjles-pta-vote/server/services"
)
func voteHandler(w http.ResponseWriter, r *http.Request) {
func voteHandler(resWriter http.ResponseWriter, request *http.Request) {
var vote models.Vote
if err := json.NewDecoder(r.Body).Decode(&vote); err != nil {
http.Error(w, "Invalid request payload", http.StatusBadRequest)
if err := json.NewDecoder(request.Body).Decode(&vote); err != nil {
common.SendError(resWriter, "Invalid JSON", http.StatusBadRequest)
return
}
err := services.SetVote(&vote)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if err := services.SetVote(&vote); err != nil {
common.SendError(resWriter, "Failed to set vote", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
resWriter.WriteHeader(http.StatusOK)
}
func voteIDHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
func voteIDHandler(resWriter http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
idStr := vars["id"]
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid poll ID", http.StatusBadRequest)
common.SendError(resWriter, "Invalid poll ID", http.StatusBadRequest)
return
}
@@ -46,121 +47,111 @@ func voteIDHandler(w http.ResponseWriter, r *http.Request) {
err = services.SetVote(&vote)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
common.SendError(resWriter, "Failed to set vote", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
resWriter.WriteHeader(http.StatusOK)
}
func statsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
func statsHandler(resWriter http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet {
filePath := "./server/templates/stats.html"
log.Printf("Serving stats.html from %s", filePath)
http.ServeFile(w, r, filePath)
} else if r.Method == "POST" {
vars := mux.Vars(r)
http.ServeFile(resWriter, request, filePath)
} else if request.Method == http.MethodPost {
vars := mux.Vars(request)
id := vars["id"]
poll, err := services.GetPollByQuestion(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
common.SendError(resWriter, "Failed to get poll", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(poll)
json.NewEncoder(resWriter).Encode(poll)
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
resWriter.WriteHeader(http.StatusMethodNotAllowed)
}
}
func statsIDHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
func statsIDHandler(resWriter http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
id := vars["id"]
poll, err := services.GetPollByQuestion(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
common.SendError(resWriter, "Failed to get poll", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(poll)
json.NewEncoder(resWriter).Encode(poll)
}
func adminHandler(w http.ResponseWriter, r *http.Request) {
// Add admin functionality here
w.WriteHeader(http.StatusOK)
func adminLoginHandler(resWriter http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
common.SendError(resWriter, "Method not allowed", http.StatusMethodNotAllowed)
return
}
func adminIDHandler(w http.ResponseWriter, r *http.Request) {
//vars := mux.Vars(r)
//id := vars["id"]
// Add admin functionality here
w.WriteHeader(http.StatusOK)
var loginReq services.LoginRequest
if err := json.NewDecoder(request.Body).Decode(&loginReq); err != nil {
common.SendError(resWriter, "Invalid JSON", http.StatusBadRequest)
return
}
func adminMembersHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
filePath := "./server/templates/members.html"
log.Printf("Serving members.html from %s", filePath)
http.ServeFile(w, r, filePath)
} else if r.Method == "POST" {
var year int
var err error
r.ParseForm()
if y := r.FormValue("year"); y != "" {
year, err = strconv.Atoi(y)
// Validate admin credentials
isValid, err := services.ValidateAdminLogin(loginReq.Username, loginReq.Password)
if err != nil {
http.Error(w, "Invalid year", http.StatusBadRequest)
common.SendError(resWriter, "Invalid username or password", http.StatusBadRequest)
return
}
if !isValid {
common.SendError(resWriter, "Invalid username or password", http.StatusUnauthorized)
return
}
file, _, err := r.FormFile("members.csv")
// Generate JWT token
token, err := services.GenerateAuthToken(loginReq.Username)
if err != nil {
http.Error(w, "Failed to upload file", http.StatusBadRequest)
return
}
defer file.Close()
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
common.SendError(resWriter, "Failed to generate auth token", http.StatusInternalServerError)
return
}
err = services.ParseMembersFromBytes(year, fileBytes)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"success": true})
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
}
resWriter.WriteHeader(http.StatusOK)
json.NewEncoder(resWriter).Encode(services.LoginResponse{
Success: true,
Token: token,
})
}
func main() {
log.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lshortfile)
http.HandleFunc("/api/vote", voteHandler)
http.HandleFunc("/api/vote/{id}", voteIDHandler)
http.HandleFunc("/api/stats", statsHandler)
http.HandleFunc("/api/stats/{id}", statsIDHandler)
http.HandleFunc("/api/admin/new-vote", services.AdminNewVoteHandler)
http.HandleFunc("/api/admin/view-votes", services.AdminViewVoteHandler)
http.HandleFunc("/api/admin/login", adminLoginHandler)
http.HandleFunc("/api/admin/members", services.AdminMembersHandler)
http.HandleFunc("/api/admin/members/view", services.AdminMembersView)
buildPath := filepath.Join(".", "client", "build")
fs := http.FileServer(http.Dir(buildPath))
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If the file exists on disk, let the file server handle it.
if _, err := os.Stat(filepath.Join(buildPath, r.URL.Path)); err == nil {
fs.ServeHTTP(w, r)
return
}
// Otherwise serve index.html (so React Router can handle the route)
http.ServeFile(w, r, filepath.Join(buildPath, "index.html"))
}))
log.Printf("Starting server on :8080")
http.HandleFunc("/vote", voteHandler)
http.HandleFunc("/vote/{id}", voteIDHandler)
http.HandleFunc("/stats", statsHandler)
http.HandleFunc("/stats/{id}", statsIDHandler)
http.HandleFunc("/admin", adminHandler)
http.HandleFunc("/admin/{id}", adminIDHandler)
http.HandleFunc("/admin/members", adminMembersHandler)
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
filePath := "./server/icons/favicon.ico"
http.ServeFile(w, r, filePath)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}

127
server/services/auth.go Normal file
View File

@@ -0,0 +1,127 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"log"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Success bool `json:"success"`
Token string `json:"token,omitempty"`
Error string `json:"error,omitempty"`
}
var jwtSecret string
func init() {
jwtSecret = os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "your-secret-key-change-in-production"
log.Println("WARNING: JWT_SECRET not set, using default value. Change this in production!")
}
}
// GetAdminCredentials retrieves admin credentials from environment variables
// Format: ADMIN_USERS=username:password|username2:password2
func getAdminCredentials() map[string]string {
adminUsers := os.Getenv("ADMIN_USERS")
if adminUsers == "" {
// Default admin user (change in production)
adminUsers = "admin:admin"
log.Println("WARNING: ADMIN_USERS not set, using default admin:admin")
}
credentials := make(map[string]string)
for _, userPass := range strings.Split(adminUsers, "|") {
parts := strings.Split(strings.TrimSpace(userPass), ":")
if len(parts) == 2 {
credentials[parts[0]] = parts[1]
}
}
return credentials
}
// hashPassword hashes a password using SHA256
func hashPassword(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
// ValidateAdminLogin checks if the provided username and password are valid
func ValidateAdminLogin(username, password string) (bool, error) {
if username == "" || password == "" {
return false, errors.New("username and password are required")
}
credentials := getAdminCredentials()
storedPassword, exists := credentials[username]
if !exists {
// Return false but not an error for security reasons (don't reveal if user exists)
return false, nil
}
// Compare passwords (you could enhance this with bcrypt in production)
if storedPassword != password {
return false, nil
}
return true, nil
}
// GenerateAuthToken generates a JWT token for an authenticated admin user
func GenerateAuthToken(username string) (string, error) {
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(jwtSecret))
if err != nil {
return "", errors.Wrap(err, "failed to generate token")
}
return tokenString, nil
}
// VerifyAuthToken verifies a JWT token and returns the username if valid
func VerifyAuthToken(tokenString string) (string, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil {
return "", errors.Wrap(err, "failed to parse token")
}
if !token.Valid {
return "", errors.New("invalid token")
}
claims, ok := token.Claims.(*jwt.MapClaims)
if !ok {
return "", errors.New("invalid token claims")
}
username, ok := (*claims)["username"].(string)
if !ok {
return "", errors.New("username not found in token")
}
return username, nil
}

236
server/services/members.go Normal file
View File

@@ -0,0 +1,236 @@
package services
import (
"encoding/csv"
"fmt"
"log"
"strings"
"net/http"
"strconv"
"io/ioutil"
"encoding/json"
"github.com/pkg/errors"
"go-sjles-pta-vote/server/common"
"go-sjles-pta-vote/server/db"
)
type Member struct {
Name string
Email string
}
const BATCH_SIZE = 100
func AdminMembersHandler(resWriter http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
resWriter.WriteHeader(http.StatusMethodNotAllowed)
return
}
var year int
var err error
if err = request.ParseMultipartForm(10 << 20); err != nil {
common.SendError(resWriter, "Failed to parse multipart form", http.StatusBadRequest)
return
}
year_from_form := request.FormValue("year")
if year_from_form == "" {
common.SendError(resWriter, "Year is required", http.StatusBadRequest)
return
} else {
year, err = strconv.Atoi(year_from_form)
if err != nil {
common.SendError(resWriter, "Invalid year", http.StatusBadRequest)
return
}
}
file, _, err := request.FormFile("members.csv")
if err != nil {
common.SendError(resWriter, "Failed to read members.csv file", http.StatusBadRequest)
return
}
defer file.Close()
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
common.SendError(resWriter, "Failed to read members.csv file", http.StatusInternalServerError)
return
}
if err = ParseMembersFromBytes(year, fileBytes); err != nil {
common.SendError(resWriter, "Failed to parse members from CSV", http.StatusBadRequest)
return
}
resWriter.WriteHeader(http.StatusOK)
json.NewEncoder(resWriter).Encode(map[string]bool{"success": true})
}
func AdminMembersView(resWriter http.ResponseWriter, request *http.Request) {
yearStr := request.URL.Query().Get("year")
if yearStr == "" {
common.SendError(resWriter, "Year is required", http.StatusBadRequest)
return
}
year, err := strconv.Atoi(yearStr)
if err != nil {
common.SendError(resWriter, "Invalid year", http.StatusBadRequest)
return
}
members, err := GetMembersByYear(year)
if err != nil {
common.SendError(resWriter, "Failed to get members", http.StatusInternalServerError)
return
}
resWriter.WriteHeader(http.StatusOK)
json.NewEncoder(resWriter).Encode(map[string]interface{}{
"success": true,
"members": members,
})
}
func ParseMembersFromBytes(year int, fileBytes []byte) error {
reader := csv.NewReader(strings.NewReader(string(fileBytes)))
reader.FieldsPerRecord = -1 // Allow variable number of fields per record
records, err := reader.ReadAll()
if err != nil {
return errors.Wrap(err, "failed to read CSV from bytes")
}
var members []Member
for i, record := range records {
if i == 0 {
continue // Skip the first line (column headers)
}
if len(record) < 4 {
continue
}
firstName := strings.TrimSpace(record[1])
lastName := strings.TrimSpace(record[2])
email := strings.TrimSpace(record[3])
members = append(members, Member{
Name: fmt.Sprintf("%s %s", firstName, lastName),
Email: email,
})
if len(record) < 30 {
continue
}
email2 := strings.TrimSpace(record[27])
if email2 != "" {
firstName2 := strings.TrimSpace(record[29])
lastName2 := strings.TrimSpace(record[28])
members = append(members, Member{
Name: fmt.Sprintf("%s %s", firstName2, lastName2),
Email: email2,
})
}
}
return saveMember(year, members)
}
func saveMember(year int, members []Member) error {
insertMembersQuery := `
INSERT OR REPLACE INTO members (email, member_name, school_year)
VALUES ($1, $2, $3)
`
log.Printf("Starting to save %d members for year %d", len(members), year)
db_conn, err := db.Connect()
if err != nil {
return errors.Wrap(err, "failed to connect to database")
}
defer db_conn.Close()
tx, err := db_conn.Begin()
if err != nil {
return errors.Wrap(err, "failed to begin transaction")
}
stmt, err := tx.Prepare(insertMembersQuery)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to prepare statement")
}
defer stmt.Close()
for index, member := range members {
_, err = stmt.Exec(member.Email, member.Name, year)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to execute insert")
}
if (index+1) % BATCH_SIZE == 0 {
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to commit transaction")
}
tx, err = db_conn.Begin()
if err != nil {
return errors.Wrap(err, "failed to begin new transaction")
}
stmt, err = tx.Prepare(insertMembersQuery)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to prepare new statement")
}
}
}
return tx.Commit()
}
func GetMembersByYear(year int) ([]Member, error) {
query := `
SELECT member_name, email
FROM members
WHERE school_year = $1
ORDER BY member_name ASC
`
db_conn, err := db.Connect()
if err != nil {
return nil, errors.Wrap(err, "failed to connect to database")
}
defer db_conn.Close()
rows, err := db_conn.Query(query, year)
if err != nil {
return nil, errors.Wrap(err, "failed to execute query")
}
defer rows.Close()
var members []Member
for rows.Next() {
var member Member
if err := rows.Scan(&member.Name, &member.Email); err != nil {
return nil, errors.Wrap(err, "failed to scan row")
}
members = append(members, member)
}
if err := rows.Err(); err != nil {
return nil, errors.Wrap(err, "row iteration error")
}
return members, nil
}

View File

@@ -4,7 +4,12 @@ import (
"database/sql"
"errors"
"time"
"log"
"encoding/json"
"strconv"
"net/http"
"go-sjles-pta-vote/server/common"
"go-sjles-pta-vote/server/db"
"go-sjles-pta-vote/server/models"
)
@@ -13,12 +18,55 @@ var ErrQuestionAlreadyExists = errors.New("Question already exists")
var ErrQuestionDoesntExist = errors.New("Question does not exist yet")
var ErrVoterAlreadyVoted = errors.New("Voter already voted")
var ErrPollNotFound = errors.New("Poll not found")
var ErrFailedToUpdateVote = errors.New("Failed to update vote")
var ErrFailedToDeletePoll = errors.New("Failed to delete poll")
func CreatePoll(poll *models.Poll) (*models.Poll, error) {
new_poll := models.Poll{}
const (
DATE_FORMAT = "2006-01-02 15:04:05"
DEFAULT_POLL_DURATION_HOURS = 24
)
func AdminNewVoteHandler(resWriter http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
resWriter.WriteHeader(http.StatusMethodNotAllowed)
return
}
question := request.FormValue("question")
if question == "" {
common.SendError(resWriter, "Question is required", http.StatusBadRequest)
return
}
durationHours := DEFAULT_POLL_DURATION_HOURS
if durationStr := request.FormValue("duration"); durationStr != "" {
var err error
durationHours, err = strconv.Atoi(durationStr)
if err != nil {
common.SendError(resWriter, "Invalid duration", http.StatusBadRequest)
return
}
}
poll := models.Poll{
Question: question,
ExpiresAt: time.Now().Add(time.Duration(durationHours) * time.Hour).Format(DATE_FORMAT),
}
_, err := CreatePoll(&poll)
if err != nil {
common.SendError(resWriter, "Failed to create poll", http.StatusInternalServerError)
return
}
resWriter.WriteHeader(http.StatusOK)
json.NewEncoder(resWriter).Encode(map[string]bool{"success": true})
}
func CreatePoll(poll *models.Poll) (*int64, error) {
db_conn, err := db.Connect()
if err != nil {
log.Fatal(err)
return nil, err
}
defer db.Close()
@@ -29,6 +77,7 @@ func CreatePoll(poll *models.Poll) (*models.Poll, error) {
WHERE question == $1
`)
if err != nil {
log.Fatal(err)
return nil, err
}
defer get_stmt.Close()
@@ -37,6 +86,7 @@ func CreatePoll(poll *models.Poll) (*models.Poll, error) {
err = get_stmt.QueryRow(poll.Question).Scan(&id)
if err != sql.ErrNoRows {
if err != nil {
log.Fatal(err)
return nil, err
}
return nil, ErrQuestionAlreadyExists
@@ -53,6 +103,7 @@ func CreatePoll(poll *models.Poll) (*models.Poll, error) {
`)
if err != nil {
log.Fatal(err)
return nil, err
}
@@ -60,19 +111,84 @@ func CreatePoll(poll *models.Poll) (*models.Poll, error) {
res, err := stmt.Exec(poll.Question, poll.ExpiresAt)
if err != nil {
log.Fatal(err)
return nil, err
}
new_poll.ID, err = res.LastInsertId()
new_poll_id, err := res.LastInsertId()
return &new_poll_id, err
}
return &new_poll, err
func AdminViewVoteHandler(resWriter http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
resWriter.WriteHeader(http.StatusMethodNotAllowed)
return
}
polls, err := GetAllPolls()
if err != nil {
common.SendError(resWriter, "Failed to get polls", http.StatusInternalServerError)
return
}
json.NewEncoder(resWriter).Encode(polls)
}
func GetAllPolls() ([]models.Poll, error) {
db_conn, err := db.Connect()
if err != nil {
log.Fatal(err)
return nil, err
}
defer db.Close()
get_polls_stmt, err := db_conn.Prepare(`
SELECT
id, question,
member_yes_votes, member_no_votes,
non_member_yes_votes, non_member_no_votes,
created_at, updated_at,
expires_at
FROM polls
`)
if err != nil {
log.Fatal(err)
return nil, err
}
defer get_polls_stmt.Close()
rows, err := get_polls_stmt.Query()
if err != nil {
log.Fatal(err)
return nil, err
}
defer rows.Close()
var polls []models.Poll
for rows.Next() {
new_poll := models.Poll{}
err = rows.Scan(
&new_poll.ID, &new_poll.Question,
&new_poll.MemberYes, &new_poll.MemberNo,
&new_poll.NonMemberYes, &new_poll.NonMemberNo,
&new_poll.CreatedAt, &new_poll.UpdatedAt,
&new_poll.ExpiresAt,
)
if err != nil {
log.Fatal(err)
return nil, err
}
polls = append(polls, new_poll)
}
return polls, nil
}
func GetPollByQuestion(question string) (*models.Poll, error) {
new_poll := models.Poll{}
db_conn, err := db.Connect()
if err != nil {
log.Fatal(err)
return nil, err
}
defer db.Close()
@@ -88,10 +204,12 @@ func GetPollByQuestion(question string) (*models.Poll, error) {
WHERE question == $1
`)
if err != nil {
log.Fatal(err)
return nil, err
}
defer get_poll_stmt.Close()
new_poll := models.Poll{}
err = get_poll_stmt.QueryRow(question).Scan(
&new_poll.ID, &new_poll.Question,
&new_poll.MemberYes, &new_poll.MemberNo,
@@ -103,6 +221,7 @@ func GetPollByQuestion(question string) (*models.Poll, error) {
if err == sql.ErrNoRows {
return nil, ErrPollNotFound
} else if err != nil {
log.Fatal(err)
return nil, err
}
@@ -117,11 +236,11 @@ func GetPollByQuestion(question string) (*models.Poll, error) {
defer get_voters_stmt.Close()
rows, err := get_voters_stmt.Query(new_poll.ID)
for rows.Next() {
var voter_email string
err = rows.Scan(&voter_email)
if err != nil {
log.Fatal(err)
return nil, err
}
new_poll.WhoVoted = append(new_poll.WhoVoted, voter_email)
@@ -136,7 +255,7 @@ func GetAndCreatePollByQuestion(question string) (*models.Poll, error) {
if err == ErrPollNotFound {
create_poll := &models.Poll{
Question: question,
ExpiresAt: time.Now().Add(time.Hour * 10).Format("2006-01-02 15:04:05"),
ExpiresAt: time.Now().Add(time.Hour * 10).Format(DATE_FORMAT),
}
if _, err = CreatePoll(create_poll); err != nil {
@@ -145,16 +264,17 @@ func GetAndCreatePollByQuestion(question string) (*models.Poll, error) {
return GetPollByQuestion(question)
} else if err != nil {
log.Fatal(err)
return nil, err
} else {
return new_poll, err
}
}
// Use models.Vote to set votes
func SetVote(vote *models.Vote) error {
db_conn, err := db.Connect()
if err != nil {
log.Fatal(err)
return err
}
defer db.Close()
@@ -165,18 +285,21 @@ func SetVote(vote *models.Vote) error {
VALUES ($1, $2)
`)
if err != nil {
log.Fatal(err)
return err
}
defer set_voter_stmt.Close()
res, err := set_voter_stmt.Exec(vote.PollId, vote.Email)
if err != nil {
log.Fatal(err)
return err
} else {
rows_changed, err := res.RowsAffected()
if rows_changed != 1 {
return ErrVoterAlreadyVoted
} else if err != nil {
log.Fatal(err)
return err
}
}
@@ -187,6 +310,7 @@ func SetVote(vote *models.Vote) error {
WHERE email == $1
`)
if err != nil {
log.Fatal(err)
return err
}
defer is_voter_member_stmt.Close()
@@ -197,6 +321,7 @@ func SetVote(vote *models.Vote) error {
if err == sql.ErrNoRows {
is_member = false
} else if err != nil {
log.Fatal(err)
return err
}
@@ -219,18 +344,21 @@ func SetVote(vote *models.Vote) error {
WHERE id == $1
`)
if err != nil {
log.Fatal(err)
return err
}
defer add_vote_stmt.Close()
res, err = add_vote_stmt.Exec(vote.PollId)
if err != nil {
log.Fatal(err)
return err
}
if num, err := res.RowsAffected(); num != 1 {
return errors.New("Failed to update votes")
return ErrFailedToUpdateVote
} else if err != nil {
log.Fatal(err)
return err
}
@@ -242,6 +370,7 @@ func DeletePollByQuestion(question string) error {
db_conn, err := db.Connect()
if err != nil {
return err
log.Fatal(err)
}
defer db.Close()
@@ -254,12 +383,14 @@ func DeletePollByQuestion(question string) error {
)
`)
if err != nil {
log.Fatal(err)
return err
}
defer delete_votes_stmt.Close()
_, err = delete_votes_stmt.Exec(question)
if err != nil {
log.Fatal(err)
return err
}
@@ -268,18 +399,21 @@ func DeletePollByQuestion(question string) error {
WHERE question == $1
`)
if err != nil {
log.Fatal(err)
return err
}
defer delete_poll_stmt.Close()
res, err := delete_poll_stmt.Exec(question)
if err != nil {
log.Fatal(err)
return err
}
if num, err := res.RowsAffected(); num != 1 {
return errors.New("Failed to delete poll")
return ErrFailedToDeletePoll
} else if err != nil {
log.Fatal(err)
return err
}

View File

@@ -1,120 +0,0 @@
package services
import (
"encoding/csv"
"fmt"
"log"
"strings"
"github.com/pkg/errors"
"go-sjles-pta-vote/server/db"
)
type Member struct {
Name string
Email string
}
BATCH_SIZE := 100
func ParseMembersFromBytes(year int, fileBytes []byte) error {
reader := csv.NewReader(strings.NewReader(string(fileBytes)))
reader.FieldsPerRecord = -1 // Allow variable number of fields per record
records, err := reader.ReadAll()
if err != nil {
return errors.Wrap(err, "failed to read CSV from bytes")
}
var members []Member
for i, record := range records {
if i == 0 {
continue // Skip the first line (column headers)
}
if len(record) < 4 {
continue
}
firstName := strings.TrimSpace(record[1])
lastName := strings.TrimSpace(record[2])
email := strings.TrimSpace(record[3])
members = append(members, Member{
Name: fmt.Sprintf("%s %s", firstName, lastName),
Email: email,
})
if len(record) < 30 {
continue
}
email2 := strings.TrimSpace(record[27])
if email2 != "" {
firstName2 := strings.TrimSpace(record[29])
lastName2 := strings.TrimSpace(record[28])
members = append(members, Member{
Name: fmt.Sprintf("%s %s", firstName2, lastName2),
Email: email2,
})
}
}
return saveMember(year, members)
}
func saveMember(year int, members []Member) error {
insertMembersQuery := `
INSERT OR REPLACE INTO members (email, member_name, school_year)
VALUES ($1, $2, $3)
`
log.Printf("Starting to save %d members for year %d", len(members), year)
db_conn, err := db.Connect()
if err != nil {
return errors.Wrap(err, "failed to connect to database")
}
defer db_conn.Close()
tx, err := db_conn.Begin()
if err != nil {
return errors.Wrap(err, "failed to begin transaction")
}
stmt, err := tx.Prepare(insertMembersQuery)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to prepare statement")
}
defer stmt.Close()
for index, member := range members {
_, err = stmt.Exec(member.Email, member.Name, year)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to execute insert")
}
if (index+1) % BATCH_SIZE == 0 {
err = tx.Commit()
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to commit transaction")
}
tx, err = db_conn.Begin()
if err != nil {
return errors.Wrap(err, "failed to begin new transaction")
}
stmt, err = tx.Prepare(insertMembersQuery)
if err != nil {
tx.Rollback()
return errors.Wrap(err, "failed to prepare new statement")
}
}
}
return tx.Commit()
}

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Upload Members</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body>
<h1>Upload Members CSV</h1>
<form action="/admin/members" method="post" enctype="multipart/form-data">
<label for="year">Year:</label>
<input type="number" id="year" name="year" required><br><br>
<label for="members.csv">CSV File:</label>
<input type="file" id="members.csv" name="members.csv" accept=".csv" required><br><br>
<button type="submit">Upload</button>
</form>
</body>
</html>

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stats</title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>