Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list maintainer.
This commit is contained in:
commit
1033cdb29b
59 changed files with 8663 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
turnpike
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
vendor/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
.direnv/
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.24-alpine AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY --from=frontend /app/frontend/dist ./frontend/dist
|
||||||
|
COPY *.go ./
|
||||||
|
RUN CGO_ENABLED=0 go build -o turnpike .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=backend /app/turnpike /turnpike
|
||||||
|
EXPOSE 8180
|
||||||
|
ENTRYPOINT ["/turnpike"]
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.PHONY: build frontend-build dev clean
|
||||||
|
|
||||||
|
build: frontend-build
|
||||||
|
CGO_ENABLED=0 go build -o turnpike .
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend && npm ci && npm run build
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Run in two terminals:"
|
||||||
|
@echo " Terminal 1: go run . --db dev.db"
|
||||||
|
@echo " Terminal 2: cd frontend && npm run dev"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f turnpike dev.db
|
||||||
|
rm -rf frontend/dist
|
||||||
98
README.md
Normal file
98
README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Turnpike
|
||||||
|
|
||||||
|
Self-hosted event attendee and volunteer management. One instance, one event.
|
||||||
|
|
||||||
|
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
||||||
|
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
|
||||||
|
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
||||||
|
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
||||||
|
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
|
||||||
|
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
||||||
|
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
||||||
|
- **Real-time** — check-ins and changes broadcast live via SSE
|
||||||
|
- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms
|
||||||
|
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Go, SQLite (WAL mode), JWT auth
|
||||||
|
- **Frontend:** Svelte 5, Dexie (IndexedDB), Vite
|
||||||
|
- **Deployment:** single static binary — no CGO, no external database
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Build (frontend + Go binary)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Run (creates admin user on first startup)
|
||||||
|
TURNPIKE_ADMIN_USER=admin TURNPIKE_ADMIN_PASSWORD=changeme ./turnpike
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8180`.
|
||||||
|
|
||||||
|
See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and reverse proxy setup.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Flag | Env var | Default | Description |
|
||||||
|
|------|---------|---------|-------------|
|
||||||
|
| `--addr` | — | `0.0.0.0:8180` | Listen address |
|
||||||
|
| `--db` | — | `turnpike.db` | SQLite database path |
|
||||||
|
| `--secret` | `TURNPIKE_SECRET` | auto-generated | JWT signing secret (persist across restarts) |
|
||||||
|
| `--token-expiry` | — | `24` | JWT lifetime in hours |
|
||||||
|
| `--base-url` | — | — | Public URL for volunteer token links |
|
||||||
|
| `--smtp-host` | — | — | SMTP server hostname |
|
||||||
|
| `--smtp-port` | — | `587` | SMTP port (587 = STARTTLS, 465 = implicit TLS) |
|
||||||
|
| `--smtp-user` | — | — | SMTP username |
|
||||||
|
| `--smtp-password` | — | — | SMTP password |
|
||||||
|
| `--smtp-from` | — | — | Sender email address |
|
||||||
|
| `--smtp-from-name` | — | — | Sender display name |
|
||||||
|
| — | `TURNPIKE_ADMIN_USER` | — | Bootstrap admin username (first run only) |
|
||||||
|
| — | `TURNPIKE_ADMIN_PASSWORD` | — | Bootstrap admin password (first run only) |
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
| Role | Access |
|
||||||
|
|------|--------|
|
||||||
|
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
||||||
|
| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings |
|
||||||
|
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
||||||
|
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
||||||
|
|
||||||
|
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Nix](https://nixos.org) + [direnv](https://direnv.net), or Go 1.24+ and Node.js 18+ installed manually.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <repo-url>
|
||||||
|
cd turnpike
|
||||||
|
direnv allow # activates Go + Node.js via flake.nix
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two-terminal dev setup:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Terminal 1 — Go API server
|
||||||
|
go run . --db dev.db
|
||||||
|
|
||||||
|
# Terminal 2 — Vite dev server (proxies /api to :8180)
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server on `:8180`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board
|
||||||
|
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
CC BY-NC-SA 4.0
|
||||||
123
auth.go
Normal file
123
auth.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int `json:"uid"`
|
||||||
|
Username string `json:"sub"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
DeptIDs []int `json:"dept_ids,omitempty"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) signToken(u *User) (string, error) {
|
||||||
|
expiry := time.Duration(app.tokenExpiry) * time.Hour
|
||||||
|
claims := Claims{
|
||||||
|
UserID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
DeptIDs: u.DepartmentIDs,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(app.secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) parseToken(tokenStr string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return []byte(app.secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, jwt.ErrTokenInvalidClaims
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(h, "Bearer ")
|
||||||
|
}
|
||||||
|
// Fallback to query param for SSE (EventSource can't set headers)
|
||||||
|
return r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAuth wraps a handler, injects claims into context via X-Claims header trick.
|
||||||
|
// We pass claims via a request-scoped value instead.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const claimsKey contextKey = "claims"
|
||||||
|
|
||||||
|
func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := bearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := app.parseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(roles) > 0 && !hasRole(claims.Role, roles) {
|
||||||
|
writeError(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRole(role string, allowed []string) bool {
|
||||||
|
for _, r := range allowed {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimsFromContext(r *http.Request) *Claims {
|
||||||
|
c, _ := r.Context().Value(claimsKey).(*Claims)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, msg string, code int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
43
broker.go
Normal file
43
broker.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Broker is a simple in-memory pub/sub for SSE events.
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[chan []byte]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBroker() *Broker {
|
||||||
|
return &Broker{clients: make(map[chan []byte]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) subscribe() chan []byte {
|
||||||
|
ch := make(chan []byte, 8)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) unsubscribe(ch chan []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) publish(event string, data any) {
|
||||||
|
payload, _ := json.Marshal(map[string]any{"event": event, "data": data})
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
docs/INSTALLATION.md
Normal file
192
docs/INSTALLATION.md
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Turnpike Installation Guide
|
||||||
|
|
||||||
|
This guide covers building, deploying, and operating Turnpike. For usage and event workflows, see [USAGE.md](USAGE.md).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Go 1.24+** (for building)
|
||||||
|
- **Node.js 18+** (for building the frontend)
|
||||||
|
- No external database — Turnpike uses embedded SQLite via a pure-Go driver
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <repo-url>
|
||||||
|
cd turnpike
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `npm ci && npm run build` in `frontend/`, then `CGO_ENABLED=0 go build -o turnpike .`. The result is a single static binary with the frontend assets embedded. No runtime dependencies.
|
||||||
|
|
||||||
|
Other make targets: `make dev` (prints two-terminal dev instructions), `make clean` (removes binary and build artifacts).
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
| Flag | Env var | Default | Description |
|
||||||
|
|------|---------|---------|-------------|
|
||||||
|
| `--addr` | — | `0.0.0.0:8180` | Listen address |
|
||||||
|
| `--db` | — | `turnpike.db` | SQLite database path |
|
||||||
|
| `--secret` | `TURNPIKE_SECRET` | auto-generated | JWT signing secret |
|
||||||
|
| `--token-expiry` | — | `24` | JWT lifetime in hours |
|
||||||
|
| `--base-url` | — | — | Public URL for volunteer token links |
|
||||||
|
| `--smtp-host` | — | — | SMTP server hostname |
|
||||||
|
| `--smtp-port` | — | `587` | SMTP port (587 = STARTTLS, 465 = implicit TLS) |
|
||||||
|
| `--smtp-user` | — | — | SMTP username |
|
||||||
|
| `--smtp-password` | — | — | SMTP password |
|
||||||
|
| `--smtp-from` | — | — | Sender email address |
|
||||||
|
| `--smtp-from-name` | — | — | Sender display name |
|
||||||
|
| — | `TURNPIKE_ADMIN_USER` | — | Bootstrap admin username (first run only) |
|
||||||
|
| — | `TURNPIKE_ADMIN_PASSWORD` | — | Bootstrap admin password (first run only) |
|
||||||
|
|
||||||
|
SMTP settings can also be configured at runtime through the Settings page (admin only). CLI flags override stored database values.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Minimal startup (creates admin user on first run)
|
||||||
|
TURNPIKE_ADMIN_USER=admin TURNPIKE_ADMIN_PASSWORD=changeme ./turnpike
|
||||||
|
|
||||||
|
# With explicit options
|
||||||
|
./turnpike \
|
||||||
|
--addr 0.0.0.0:8180 \
|
||||||
|
--db /var/lib/turnpike/turnpike.db \
|
||||||
|
--secret your-jwt-secret \
|
||||||
|
--base-url https://turnpike.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin account is only created on first startup when no users exist and both `TURNPIKE_ADMIN_USER` and `TURNPIKE_ADMIN_PASSWORD` are set. After the first user is created, these environment variables have no effect.
|
||||||
|
|
||||||
|
### JWT Secret
|
||||||
|
|
||||||
|
If `--secret` / `TURNPIKE_SECRET` is not provided, Turnpike auto-generates a secret and stores it in the database. This persists across restarts as long as the database file is preserved. To use an explicit secret (recommended for production), pass it via flag or env var.
|
||||||
|
|
||||||
|
Changing or losing the JWT secret invalidates all active sessions — users will need to log in again.
|
||||||
|
|
||||||
|
## Systemd
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Turnpike event management
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/turnpike --db /var/lib/turnpike/turnpike.db --base-url https://turnpike.example.com
|
||||||
|
Environment=TURNPIKE_SECRET=your-secret-here
|
||||||
|
EnvironmentFile=/etc/turnpike/admin.env
|
||||||
|
StateDirectory=turnpike
|
||||||
|
DynamicUser=true
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
The `admin.env` file should contain:
|
||||||
|
```
|
||||||
|
TURNPIKE_ADMIN_USER=admin
|
||||||
|
TURNPIKE_ADMIN_PASSWORD=your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: systemd uses `EnvironmentFile` (singular). The plural `EnvironmentFiles` is silently ignored.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
A multi-stage `Dockerfile` is included in the repo (node build, Go build, scratch final image):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t turnpike .
|
||||||
|
docker run -p 8180:8180 \
|
||||||
|
-v turnpike-data:/data \
|
||||||
|
-e TURNPIKE_ADMIN_USER=admin \
|
||||||
|
-e TURNPIKE_ADMIN_PASSWORD=changeme \
|
||||||
|
-e TURNPIKE_SECRET=your-secret \
|
||||||
|
turnpike --db /data/turnpike.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## NixOS
|
||||||
|
|
||||||
|
Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO):
|
||||||
|
|
||||||
|
```nix
|
||||||
|
turnpike = pkgs.buildGoModule {
|
||||||
|
pname = "turnpike";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
|
||||||
|
vendorHash = null;
|
||||||
|
env.CGO_ENABLED = 0;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The source directory must contain:
|
||||||
|
- Go source files and `vendor/` (run `go mod vendor`)
|
||||||
|
- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`)
|
||||||
|
|
||||||
|
A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`.
|
||||||
|
|
||||||
|
## Reverse Proxy
|
||||||
|
|
||||||
|
Turnpike serves both the API and frontend on a single port. Behind a reverse proxy, ensure SSE connections for real-time sync are not buffered.
|
||||||
|
|
||||||
|
### nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name turnpike.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8180;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/sync/stream {
|
||||||
|
proxy_pass http://127.0.0.1:8180;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/api/sync/stream` endpoint uses Server-Sent Events (SSE). The key settings are `proxy_buffering off` and a long `proxy_read_timeout` to keep the connection alive.
|
||||||
|
|
||||||
|
## TLS / HTTPS
|
||||||
|
|
||||||
|
Use a TLS termination proxy (nginx, Caddy, Traefik) with Let's Encrypt or your own certificates. Turnpike itself serves plain HTTP.
|
||||||
|
|
||||||
|
Set `--base-url https://turnpike.example.com` so that volunteer token links in exported CSVs and emails use the correct URL.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Turnpike stores all data in a single SQLite file (the path passed to `--db`).
|
||||||
|
|
||||||
|
**Simple backup:** copy the `.db` file while the server is running — SQLite WAL mode ensures a consistent snapshot.
|
||||||
|
|
||||||
|
**Continuous replication:** [Litestream](https://litestream.io) streams WAL changes to S3, GCS, or Azure Blob Storage with near-zero overhead.
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
1. Pull the latest source
|
||||||
|
2. Rebuild: `make build`
|
||||||
|
3. Replace the binary and restart the service
|
||||||
|
|
||||||
|
Database migrations run automatically on startup. All migrations are additive (`ALTER TABLE ... ADD COLUMN`) — no data is lost.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Login fails after restart:** If the JWT secret wasn't persisted (`--secret` or `TURNPIKE_SECRET`), a new secret is generated on restart, invalidating all existing tokens. Users need to log in again. To prevent this, always set an explicit secret in production.
|
||||||
|
|
||||||
|
**Admin account not created:** The bootstrap admin is only created on first startup when no users exist. If the service started without `TURNPIKE_ADMIN_USER` / `TURNPIKE_ADMIN_PASSWORD`, users were never created. Delete the database file and restart with the env vars set.
|
||||||
|
|
||||||
|
**Soft deletes:** Records are marked with `deleted_at`, not removed from the database. This is required for sync — clients need to see deletions to purge their local IndexedDB. Don't hard-delete records directly from the SQLite database.
|
||||||
|
|
||||||
|
**`EnvironmentFile` vs `EnvironmentFiles`:** systemd's directive is `EnvironmentFile` (singular). Using the plural form silently ignores the file — the service starts but environment variables are never set.
|
||||||
|
|
||||||
|
**SSE disconnections:** If real-time updates stop working behind a proxy, check that `proxy_buffering off` is set for `/api/sync/stream`. The client reconnects automatically, but buffering proxies can prevent events from reaching the browser.
|
||||||
183
docs/USAGE.md
Normal file
183
docs/USAGE.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# Turnpike Usage Guide
|
||||||
|
|
||||||
|
This guide is for event organizers and ops teams running a Turnpike instance. For installation and deployment, see [INSTALLATION.md](INSTALLATION.md).
|
||||||
|
|
||||||
|
## First Login
|
||||||
|
|
||||||
|
On first startup with `TURNPIKE_ADMIN_USER` and `TURNPIKE_ADMIN_PASSWORD` set, Turnpike creates a bootstrap admin account. Log in at `https://your-instance/` with those credentials.
|
||||||
|
|
||||||
|
After logging in, create accounts for your team under **Users**. Each user gets a username, password, and role. The admin bootstrap credentials are only used on initial setup — they have no effect on subsequent restarts.
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
| Role | What they see | What they can do |
|
||||||
|
|------|--------------|------------------|
|
||||||
|
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
||||||
|
| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
||||||
|
| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
||||||
|
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
||||||
|
|
||||||
|
Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
|
||||||
|
|
||||||
|
Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
|
||||||
|
|
||||||
|
## Event Setup
|
||||||
|
|
||||||
|
1. **Configure your event** — go to the Dashboard and set the event name and dates.
|
||||||
|
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
||||||
|
3. **Import attendees** — see next section.
|
||||||
|
4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity.
|
||||||
|
|
||||||
|
## Importing Attendees
|
||||||
|
|
||||||
|
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
|
### CrowdWork / Zeffy format
|
||||||
|
|
||||||
|
| Column | Maps to |
|
||||||
|
|--------|---------|
|
||||||
|
| `Patron Name` | Name |
|
||||||
|
| `Patron Email` | Email |
|
||||||
|
| `Order Number` | Ticket ID |
|
||||||
|
| `Tier Name` | Ticket type |
|
||||||
|
|
||||||
|
### Generic format
|
||||||
|
|
||||||
|
| Column | Maps to |
|
||||||
|
|--------|---------|
|
||||||
|
| `name` (required) | Name |
|
||||||
|
| `email` | Email |
|
||||||
|
| `ticket_id` | Ticket ID |
|
||||||
|
| `ticket_type` | Ticket type |
|
||||||
|
| `note` | Note |
|
||||||
|
|
||||||
|
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
|
||||||
|
|
||||||
|
### Party-size dedup
|
||||||
|
|
||||||
|
CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically:
|
||||||
|
|
||||||
|
- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1`
|
||||||
|
- Subsequent rows with the same name + order number increment `party_size` (no duplicate record)
|
||||||
|
- Result: one attendee record, `party_size=3` if three tickets were purchased
|
||||||
|
|
||||||
|
The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates).
|
||||||
|
|
||||||
|
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
|
||||||
|
|
||||||
|
## Managing Volunteers
|
||||||
|
|
||||||
|
Under **Volunteers**, you can:
|
||||||
|
|
||||||
|
- Create volunteers manually (name, email, department)
|
||||||
|
- Link a volunteer to an existing attendee record (for dual check-in at the gate)
|
||||||
|
- Assign volunteers to departments
|
||||||
|
- Check in volunteers
|
||||||
|
|
||||||
|
Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously.
|
||||||
|
|
||||||
|
## Shift Scheduling
|
||||||
|
|
||||||
|
Under **Shifts**, create shifts for each department:
|
||||||
|
|
||||||
|
- **Day** — the date of the shift
|
||||||
|
- **Start/end time** — HH:MM format
|
||||||
|
- **Capacity** — maximum number of volunteers
|
||||||
|
|
||||||
|
### Assigning volunteers
|
||||||
|
|
||||||
|
From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment.
|
||||||
|
|
||||||
|
### Reordering
|
||||||
|
|
||||||
|
Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering.
|
||||||
|
|
||||||
|
## Volunteer Kiosk
|
||||||
|
|
||||||
|
The kiosk lets volunteers self-select shifts without logging in.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one.
|
||||||
|
2. **Distribute tokens** — two options:
|
||||||
|
- **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
|
||||||
|
- **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
|
||||||
|
3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
|
||||||
|
|
||||||
|
### Volunteer experience
|
||||||
|
|
||||||
|
Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing:
|
||||||
|
|
||||||
|
- Their name and department
|
||||||
|
- Currently assigned shifts
|
||||||
|
- Available shifts with remaining capacity
|
||||||
|
|
||||||
|
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
|
||||||
|
|
||||||
|
No login is required. The 8-character token authenticates the request.
|
||||||
|
|
||||||
|
### Token format
|
||||||
|
|
||||||
|
Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
||||||
|
|
||||||
|
## Gate Check-In
|
||||||
|
|
||||||
|
Users with the **gate** role see a dedicated full-screen UI:
|
||||||
|
|
||||||
|
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
||||||
|
- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline).
|
||||||
|
- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining."
|
||||||
|
- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously.
|
||||||
|
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
||||||
|
|
||||||
|
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
||||||
|
|
||||||
|
## Schedule Board
|
||||||
|
|
||||||
|
The Schedule Board is the primary UI for coordinators and volunteer leads. It shows:
|
||||||
|
|
||||||
|
- Shifts grouped by department and day
|
||||||
|
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
||||||
|
- Conflict badges when a volunteer has overlapping shifts on the same day
|
||||||
|
|
||||||
|
**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
|
||||||
|
|
||||||
|
Actions available:
|
||||||
|
- Assign volunteers to shifts from a dropdown
|
||||||
|
- Remove volunteer assignments
|
||||||
|
- Reorder shifts within a department
|
||||||
|
- Edit shift details inline
|
||||||
|
|
||||||
|
## SMTP Configuration
|
||||||
|
|
||||||
|
SMTP enables token email distribution and test emails. Configure in **Settings** (admin only):
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| SMTP Host | Mail server hostname (e.g., `smtp.fastmail.com`) |
|
||||||
|
| SMTP Port | `587` for STARTTLS (default), `465` for implicit TLS |
|
||||||
|
| SMTP User | Login username |
|
||||||
|
| SMTP Password | Login password |
|
||||||
|
| From Address | Sender email address |
|
||||||
|
| From Name | Sender display name |
|
||||||
|
|
||||||
|
After saving, use "Send Test Email" to verify the configuration.
|
||||||
|
|
||||||
|
SMTP can also be set via CLI flags (`--smtp-host`, etc.) which override database values.
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
Turnpike is a Progressive Web App (PWA). After the first load, it works offline:
|
||||||
|
|
||||||
|
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
|
||||||
|
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
|
||||||
|
- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order.
|
||||||
|
|
||||||
|
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
|
||||||
|
|
||||||
|
## CSV Exports
|
||||||
|
|
||||||
|
Two CSV exports are available from the Attendees page:
|
||||||
|
|
||||||
|
- **Attendee export** — all attendee records with check-in status
|
||||||
|
- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns.
|
||||||
140
email.go
Normal file
140
email.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
FromName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSMTPConfig reads SMTP settings from the config table, overlaying any
|
||||||
|
// values set via CLI flags (which take priority).
|
||||||
|
func (app *App) loadSMTPConfig() SMTPConfig {
|
||||||
|
get := func(key string) string {
|
||||||
|
var v string
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := SMTPConfig{
|
||||||
|
Host: app.smtpHost,
|
||||||
|
Port: app.smtpPort,
|
||||||
|
User: app.smtpUser,
|
||||||
|
Password: app.smtpPassword,
|
||||||
|
From: app.smtpFrom,
|
||||||
|
FromName: app.smtpFromName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = get("smtp_host")
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
fmt.Sscanf(get("smtp_port"), "%d", &cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = get("smtp_user")
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
cfg.Password = get("smtp_password")
|
||||||
|
}
|
||||||
|
if cfg.From == "" {
|
||||||
|
cfg.From = get("smtp_from")
|
||||||
|
}
|
||||||
|
if cfg.FromName == "" {
|
||||||
|
cfg.FromName = get("smtp_from_name")
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 587
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmail delivers a plain-text email.
|
||||||
|
// Uses implicit TLS on port 465, STARTTLS on all other ports.
|
||||||
|
func sendEmail(cfg SMTPConfig, to, subject, body string) error {
|
||||||
|
fromHeader := cfg.From
|
||||||
|
if cfg.FromName != "" {
|
||||||
|
fromHeader = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From)
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||||
|
fromHeader, to, subject, body,
|
||||||
|
)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
||||||
|
|
||||||
|
if cfg.Port == 465 {
|
||||||
|
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tls dial: %w", err)
|
||||||
|
}
|
||||||
|
c, err := smtp.NewClient(conn, cfg.Host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp client: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err = c.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
if err = c.Mail(cfg.From); err != nil {
|
||||||
|
return fmt.Errorf("smtp mail from: %w", err)
|
||||||
|
}
|
||||||
|
if err = c.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("smtp rcpt: %w", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = fmt.Fprint(w, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTokenEmail sends a volunteer token link to the attendee's email address.
|
||||||
|
func (app *App) sendTokenEmail(a Attendee) error {
|
||||||
|
if a.Email == "" {
|
||||||
|
return fmt.Errorf("attendee has no email address")
|
||||||
|
}
|
||||||
|
if a.VolunteerToken == nil || *a.VolunteerToken == "" {
|
||||||
|
return fmt.Errorf("attendee has no volunteer token")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := app.loadSMTPConfig()
|
||||||
|
|
||||||
|
baseURL := app.baseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
|
||||||
|
}
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
|
||||||
|
event, _ := app.getEvent()
|
||||||
|
eventName := "the event"
|
||||||
|
if event != nil && event.Name != "" {
|
||||||
|
eventName = event.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||||
|
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n",
|
||||||
|
a.Name, eventName, *a.VolunteerToken, link,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sendEmail(cfg, a.Email, subject, body)
|
||||||
|
}
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772419343,
|
||||||
|
"narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
28
flake.nix
Normal file
28
flake.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
description = "Turnpike — event attendee & volunteer management";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in {
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
go
|
||||||
|
gopls
|
||||||
|
gotools
|
||||||
|
nodejs_22
|
||||||
|
nodePackages.npm
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "turnpike dev — go $(go version | awk '{print $3}'), node $(node --version)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
43
frontend/README.md
Normal file
43
frontend/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Svelte + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `checkJs` in the JS template?**
|
||||||
|
|
||||||
|
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.js
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#6366f1" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>Turnpike</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
frontend/jsconfig.json
Normal file
33
frontend/jsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
/**
|
||||||
|
* svelte-preprocess cannot figure out whether you have
|
||||||
|
* a value or a type, so tell TypeScript to enforce using
|
||||||
|
* `import type` instead of `import` for Types.
|
||||||
|
*/
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* To have warnings / errors of the Svelte compiler at the
|
||||||
|
* correct position, enable source maps by default.
|
||||||
|
*/
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable this if you'd like to use dynamic types.
|
||||||
|
*/
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Use global.d.ts instead of compilerOptions.types
|
||||||
|
* to avoid limiting type declarations.
|
||||||
|
*/
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
1391
frontend/package-lock.json
generated
Normal file
1391
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
"svelte": "^5.45.2",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dexie": "^4.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/public/manifest.json
Normal file
13
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "Turnpike",
|
||||||
|
"short_name": "Turnpike",
|
||||||
|
"description": "Event attendee & volunteer management",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f1117",
|
||||||
|
"theme_color": "#6366f1",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
102
frontend/src/App.svelte
Normal file
102
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { getSession, clearSession } from './db.js'
|
||||||
|
import { syncPull, startSSE, startSyncLoop } from './sync.js'
|
||||||
|
import Login from './pages/Login.svelte'
|
||||||
|
import Dashboard from './pages/Dashboard.svelte'
|
||||||
|
import Attendees from './pages/Attendees.svelte'
|
||||||
|
import Volunteers from './pages/Volunteers.svelte'
|
||||||
|
import Departments from './pages/Departments.svelte'
|
||||||
|
import Shifts from './pages/Shifts.svelte'
|
||||||
|
import Users from './pages/Users.svelte'
|
||||||
|
import Import from './pages/Import.svelte'
|
||||||
|
import Kiosk from './pages/Kiosk.svelte'
|
||||||
|
import GateUI from './pages/GateUI.svelte'
|
||||||
|
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||||
|
import Settings from './pages/Settings.svelte'
|
||||||
|
import Nav from './components/Nav.svelte'
|
||||||
|
import SyncStatus from './components/SyncStatus.svelte'
|
||||||
|
|
||||||
|
let session = $state(null)
|
||||||
|
let loading = $state(true)
|
||||||
|
let route = $state(window.location.hash || '#/')
|
||||||
|
|
||||||
|
// Check if this is a kiosk token URL before doing anything else
|
||||||
|
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Kiosk pages don't need auth
|
||||||
|
if (kioskToken) {
|
||||||
|
loading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session = await getSession()
|
||||||
|
loading = false
|
||||||
|
if (session) {
|
||||||
|
await syncPull()
|
||||||
|
startSSE()
|
||||||
|
startSyncLoop()
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
route = window.location.hash || '#/'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function onLogin(s) {
|
||||||
|
session = s
|
||||||
|
window.location.hash = '#/'
|
||||||
|
syncPull().then(() => { startSSE(); startSyncLoop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await clearSession()
|
||||||
|
session = null
|
||||||
|
window.location.hash = '#/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = $derived(route.replace(/^#/, '') || '/')
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<!-- checking session -->
|
||||||
|
{:else if kioskToken}
|
||||||
|
<Kiosk />
|
||||||
|
{:else if !session}
|
||||||
|
<Login onlogin={onLogin} />
|
||||||
|
{:else if role === 'gate'}
|
||||||
|
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
||||||
|
<GateUI {session} {onLogout} />
|
||||||
|
{:else}
|
||||||
|
<div class="layout">
|
||||||
|
<Nav {session} {onLogout} active={path} />
|
||||||
|
<div class="main">
|
||||||
|
{#if path === '/' || path === ''}
|
||||||
|
{#if role === 'volunteer_lead'}
|
||||||
|
<ScheduleBoard {session} />
|
||||||
|
{:else}
|
||||||
|
<Dashboard {session} />
|
||||||
|
{/if}
|
||||||
|
{:else if path.startsWith('/attendees')}
|
||||||
|
<Attendees {session} />
|
||||||
|
{:else if path.startsWith('/volunteers')}
|
||||||
|
<Volunteers {session} />
|
||||||
|
{:else if path.startsWith('/departments')}
|
||||||
|
<Departments {session} />
|
||||||
|
{:else if path.startsWith('/shifts')}
|
||||||
|
<Shifts {session} />
|
||||||
|
{:else if path.startsWith('/schedule')}
|
||||||
|
<ScheduleBoard {session} />
|
||||||
|
{:else if path.startsWith('/users')}
|
||||||
|
<Users {session} />
|
||||||
|
{:else if path.startsWith('/import')}
|
||||||
|
<Import {session} />
|
||||||
|
{:else if path.startsWith('/settings')}
|
||||||
|
<Settings {session} />
|
||||||
|
{:else}
|
||||||
|
<div class="page"><p class="text-muted">Page not found.</p></div>
|
||||||
|
{/if}
|
||||||
|
<SyncStatus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
140
frontend/src/api.js
Normal file
140
frontend/src/api.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { db } from './db.js'
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
const session = await db.session.get(1)
|
||||||
|
return session?.token ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(path, options = {}) {
|
||||||
|
const token = await getToken()
|
||||||
|
const headers = {}
|
||||||
|
// Don't set Content-Type for FormData — browser sets it with correct boundary
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
Object.assign(headers, options.headers)
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(path, { ...options, headers })
|
||||||
|
if (res.status === 401) {
|
||||||
|
await db.session.clear()
|
||||||
|
window.location.hash = '#/login'
|
||||||
|
throw new Error('unauthorized')
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiJSON(path, options = {}) {
|
||||||
|
const res = await apiFetch(path, options)
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated fetch for the kiosk (no JWT, no redirect on 401)
|
||||||
|
async function kioskFetch(path, options = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json', ...options.headers }
|
||||||
|
const res = await fetch(path, { ...options, headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
const e = new Error(err.error || res.statusText)
|
||||||
|
e.status = res.status
|
||||||
|
e.body = err
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (username, password) =>
|
||||||
|
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||||
|
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||||
|
me: () => apiJSON('/api/me'),
|
||||||
|
event: {
|
||||||
|
get: () => apiJSON('/api/event'),
|
||||||
|
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
|
||||||
|
get: (id) => apiJSON(`/api/attendees/${id}`),
|
||||||
|
create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }),
|
||||||
|
checkIn: (id, opts = {}) =>
|
||||||
|
apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }),
|
||||||
|
generateTokens: () =>
|
||||||
|
apiJSON('/api/attendees/generate-tokens', { method: 'POST' }),
|
||||||
|
emailToken: (id) =>
|
||||||
|
apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }),
|
||||||
|
emailAllTokens: () =>
|
||||||
|
apiJSON('/api/attendees/email-tokens', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
volunteers: {
|
||||||
|
list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)),
|
||||||
|
get: (id) => apiJSON(`/api/volunteers/${id}`),
|
||||||
|
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||||
|
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
||||||
|
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
|
||||||
|
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
departments: {
|
||||||
|
list: () => apiJSON('/api/departments'),
|
||||||
|
create: (data) => apiJSON('/api/departments', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id, data) => apiJSON(`/api/departments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: (id) => apiFetch(`/api/departments/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
shifts: {
|
||||||
|
list: (params = {}) => apiJSON('/api/shifts?' + new URLSearchParams(params)),
|
||||||
|
create: (data) => apiJSON('/api/shifts', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id, data) => apiJSON(`/api/shifts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: (id) => apiFetch(`/api/shifts/${id}`, { method: 'DELETE' }),
|
||||||
|
assignVolunteer: (shiftId, volunteerId, force = false) =>
|
||||||
|
apiFetch(`/api/shifts/${shiftId}/volunteers`, { method: 'POST', body: JSON.stringify({ volunteer_id: volunteerId, force }) }),
|
||||||
|
unassignVolunteer: (shiftId, volunteerId) =>
|
||||||
|
apiFetch(`/api/shifts/${shiftId}/volunteers/${volunteerId}`, { method: 'DELETE' }),
|
||||||
|
reorder: (positions) =>
|
||||||
|
apiFetch('/api/shifts/reorder', { method: 'POST', body: JSON.stringify(positions) }),
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
list: () => apiJSON('/api/users'),
|
||||||
|
create: (data) => apiJSON('/api/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id, data) => apiJSON(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
delete: (id) => apiFetch(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get: () => apiJSON('/api/settings'),
|
||||||
|
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
||||||
|
},
|
||||||
|
import: async (formData) => {
|
||||||
|
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
pull: (since) => apiJSON('/api/sync/pull' + (since ? `?since=${encodeURIComponent(since)}` : '')),
|
||||||
|
},
|
||||||
|
kiosk: {
|
||||||
|
get: (token) => kioskFetch(`/api/v/${token}`),
|
||||||
|
// claim returns {conflict: true, conflicting_shifts: [...]} on 409, or the updated kiosk state on success.
|
||||||
|
claim: async (token, shiftId, force = false) => {
|
||||||
|
const res = await fetch(`/api/v/${token}/shifts/${shiftId}${force ? '?force=true' : ''}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
if (res.status === 409) return { conflict: true, ...body }
|
||||||
|
if (!res.ok) throw new Error(body.error || res.statusText)
|
||||||
|
return body
|
||||||
|
},
|
||||||
|
unclaim: (token, shiftId) =>
|
||||||
|
kioskFetch(`/api/v/${token}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
}
|
||||||
177
frontend/src/app.css
Normal file
177
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
:root {
|
||||||
|
--c-bg: #0f1117;
|
||||||
|
--c-surface: #1a1d27;
|
||||||
|
--c-border: #2a2d3a;
|
||||||
|
--c-text: #e2e4ed;
|
||||||
|
--c-muted: #7a7f96;
|
||||||
|
--c-accent: #6366f1;
|
||||||
|
--c-accent-h: #818cf8;
|
||||||
|
--c-success: #22c55e;
|
||||||
|
--c-warn: #f59e0b;
|
||||||
|
--c-danger: #ef4444;
|
||||||
|
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--font: system-ui, -apple-system, sans-serif;
|
||||||
|
--transition: 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--c-accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--c-accent-h); }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
.sidebar {
|
||||||
|
width: 220px; flex-shrink: 0;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-right: 1px solid var(--c-border);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
padding: 0 1.25rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.sidebar-brand span { color: var(--c-accent); }
|
||||||
|
.nav-link {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
color: var(--c-muted); font-size: 0.9rem;
|
||||||
|
transition: color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: var(--c-text); background: rgba(255,255,255,0.04); }
|
||||||
|
.nav-link.active { color: var(--c-text); background: rgba(99,102,241,0.12); }
|
||||||
|
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
.page { padding: 2rem; flex: 1; }
|
||||||
|
.page-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.page-title { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.stat { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
|
||||||
|
.stat-label { font-size: 0.78rem; color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.stat-value { font-size: 2rem; font-weight: 700; margin-top: 0.2rem; letter-spacing: -0.03em; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.45rem 1rem; border-radius: var(--radius);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem; font-weight: 500; font-family: var(--font);
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
transition: background var(--transition), border-color var(--transition), opacity var(--transition);
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--c-accent); color: #fff; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
|
||||||
|
.btn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
|
||||||
|
.btn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
|
||||||
|
.btn-danger { background: transparent; color: var(--c-danger); border-color: var(--c-danger); }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: var(--c-danger); color: #fff; }
|
||||||
|
.btn-success { background: var(--c-success); color: #000; font-weight: 600; }
|
||||||
|
.btn-success:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1rem; }
|
||||||
|
label { font-size: 0.82rem; color: var(--c-muted); font-weight: 500; }
|
||||||
|
input, select, textarea {
|
||||||
|
background: var(--c-bg); border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--radius); color: var(--c-text);
|
||||||
|
font-size: 0.9rem; padding: 0.5rem 0.75rem;
|
||||||
|
width: 100%; font-family: var(--font);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
||||||
|
input::placeholder { color: var(--c-muted); }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.search-bar input { max-width: 320px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||||
|
th {
|
||||||
|
text-align: left; font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
padding: 0.6rem 1rem; border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--c-border); vertical-align: middle; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 0.18rem 0.55rem; border-radius: 99px;
|
||||||
|
font-size: 0.72rem; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
||||||
|
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
|
||||||
|
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||||
|
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert { padding: 0.75rem 1rem; border-radius: var(--radius); font-size: 0.875rem; margin-bottom: 1rem; }
|
||||||
|
.alert-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
|
||||||
|
.alert-success { background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); color: #86efac; }
|
||||||
|
|
||||||
|
/* Sync indicator */
|
||||||
|
.sync-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.25rem; margin-top: auto;
|
||||||
|
font-size: 0.78rem; color: var(--c-muted);
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.sync-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--c-muted); }
|
||||||
|
.sync-dot.online { background: var(--c-success); }
|
||||||
|
.sync-dot.syncing { background: var(--c-warn); animation: pulse 1s infinite; }
|
||||||
|
.sync-dot.offline { background: var(--c-danger); }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-box { width: 100%; max-width: 380px; }
|
||||||
|
.login-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||||||
|
.login-sub { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
.dept-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.empty { text-align: center; padding: 3rem 1rem; color: var(--c-muted); }
|
||||||
|
.empty p { margin-top: 0.5rem; font-size: 0.875rem; }
|
||||||
|
.text-muted { color: var(--c-muted); }
|
||||||
|
.text-success { color: var(--c-success); }
|
||||||
|
.text-danger { color: var(--c-danger); }
|
||||||
|
.flex { display: flex; align-items: center; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.actions { display: flex; gap: 0.4rem; align-items: center; }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.page { padding: 1rem; }
|
||||||
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
13
frontend/src/components/CheckInButton.svelte
Normal file
13
frontend/src/components/CheckInButton.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script>
|
||||||
|
let { onclick } = $props()
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
async function handle() {
|
||||||
|
loading = true
|
||||||
|
try { await onclick() } finally { loading = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||||
|
{loading ? '…' : '✓ Check in'}
|
||||||
|
</button>
|
||||||
57
frontend/src/components/Nav.svelte
Normal file
57
frontend/src/components/Nav.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script>
|
||||||
|
let { session, active, onLogout } = $props()
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
|
||||||
|
// Role-specific nav sets
|
||||||
|
const links = $derived.by(() => {
|
||||||
|
if (role === 'ticketing') return [
|
||||||
|
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||||
|
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||||
|
]
|
||||||
|
if (role === 'volunteer_lead') return [
|
||||||
|
{ href: '#/', label: 'Schedule', icon: '◷' },
|
||||||
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
|
]
|
||||||
|
if (role === 'coordinator') return [
|
||||||
|
{ href: '#/', label: 'Dashboard', icon: '⊞' },
|
||||||
|
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
|
||||||
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
|
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
|
||||||
|
]
|
||||||
|
// admin — all links
|
||||||
|
return [
|
||||||
|
{ href: '#/', label: 'Dashboard', icon: '⊞' },
|
||||||
|
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||||
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
|
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
|
||||||
|
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
|
||||||
|
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||||
|
{ href: '#/users', label: 'Users', icon: '⊕' },
|
||||||
|
{ href: '#/settings', label: 'Settings', icon: '⚙' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function isActive(href) {
|
||||||
|
const p = href.replace(/^#/, '')
|
||||||
|
if (p === '/') return active === '/' || active === ''
|
||||||
|
return active.startsWith(p)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-brand">Turn<span>pike</span></div>
|
||||||
|
{#each links as link}
|
||||||
|
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
|
||||||
|
<span class="icon">{link.icon}</span>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="nav-link btn-ghost" style="border:none;cursor:pointer;width:100%;text-align:left" onclick={onLogout}>
|
||||||
|
<span class="icon">→</span> Sign out
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
46
frontend/src/components/SyncStatus.svelte
Normal file
46
frontend/src/components/SyncStatus.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { getLastSync } from '../db.js'
|
||||||
|
import { syncPull } from '../sync.js'
|
||||||
|
|
||||||
|
let online = $state(navigator.onLine)
|
||||||
|
let syncing = $state(false)
|
||||||
|
let lastSync = $state('')
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
lastSync = await getLastSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualSync() {
|
||||||
|
syncing = true
|
||||||
|
await syncPull()
|
||||||
|
await refresh()
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnline() { online = true; manualSync() }
|
||||||
|
function onOffline() { online = false }
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refresh()
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
|
})
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.removeEventListener('offline', onOffline)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dotClass = $derived(syncing ? 'syncing' : online ? 'online' : 'offline')
|
||||||
|
const label = $derived(syncing ? 'Syncing…' : online ? 'Online' : 'Offline')
|
||||||
|
const lastSyncLabel = $derived(lastSync ? new Date(lastSync).toLocaleTimeString() : 'Never')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sync-bar">
|
||||||
|
<div class="sync-dot {dotClass}"></div>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span class="text-muted" style="margin-left:auto;font-size:0.72rem">Last sync: {lastSyncLabel}</span>
|
||||||
|
{#if online && !syncing}
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={manualSync}>↻</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
49
frontend/src/db.js
Normal file
49
frontend/src/db.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import Dexie from 'dexie'
|
||||||
|
|
||||||
|
export const db = new Dexie('turnpike')
|
||||||
|
|
||||||
|
db.version(1).stores({
|
||||||
|
session: 'id, token, user',
|
||||||
|
meta: 'key',
|
||||||
|
event: 'id',
|
||||||
|
attendees: 'id, name, ticket_type, checked_in, deleted_at',
|
||||||
|
departments: 'id, name, deleted_at',
|
||||||
|
volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at',
|
||||||
|
shifts: 'id, department_id, day, deleted_at',
|
||||||
|
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
|
||||||
|
outbox: '++id, table, op, synced_at',
|
||||||
|
})
|
||||||
|
|
||||||
|
db.version(2).stores({
|
||||||
|
session: 'id, token, user',
|
||||||
|
meta: 'key',
|
||||||
|
event: 'id',
|
||||||
|
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
|
||||||
|
departments: 'id, name, deleted_at',
|
||||||
|
volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at',
|
||||||
|
shifts: 'id, department_id, day, position, deleted_at',
|
||||||
|
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
|
||||||
|
outbox: '++id, table, op, synced_at',
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getLastSync() {
|
||||||
|
const m = await db.meta.get('last_sync')
|
||||||
|
return m?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastSync(ts) {
|
||||||
|
await db.meta.put({ key: 'last_sync', value: ts })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
return db.session.get(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(token, user) {
|
||||||
|
await db.session.put({ id: 1, token, user })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSession() {
|
||||||
|
await db.session.clear()
|
||||||
|
await db.meta.clear()
|
||||||
|
}
|
||||||
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { mount } from 'svelte'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
274
frontend/src/pages/Attendees.svelte
Normal file
274
frontend/src/pages/Attendees.svelte
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import CheckInButton from '../components/CheckInButton.svelte'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let search = $state('')
|
||||||
|
let filterType = $state('')
|
||||||
|
let filterChecked = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let success = $state('')
|
||||||
|
let showAdd = $state(false)
|
||||||
|
let newName = $state('')
|
||||||
|
let newEmail = $state('')
|
||||||
|
let newPhone = $state('')
|
||||||
|
let newTicketID = $state('')
|
||||||
|
let newTicketType = $state('')
|
||||||
|
let newNote = $state('')
|
||||||
|
let adding = $state(false)
|
||||||
|
let generating = $state(false)
|
||||||
|
let emailing = $state(false)
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||||
|
const canCheckIn = $derived(['admin', 'ticketing', 'gate'].includes(role))
|
||||||
|
|
||||||
|
const allAttendees = liveQuery(() => db.attendees.toArray())
|
||||||
|
const ticketTypes = liveQuery(() =>
|
||||||
|
db.attendees.orderBy('ticket_type').uniqueKeys()
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const list = $allAttendees ?? []
|
||||||
|
const s = search.toLowerCase()
|
||||||
|
return list
|
||||||
|
.filter(a => {
|
||||||
|
if (filterType && a.ticket_type !== filterType) return false
|
||||||
|
if (filterChecked === 'true' && !a.checked_in) return false
|
||||||
|
if (filterChecked === 'false' && a.checked_in) return false
|
||||||
|
if (s && !a.name.toLowerCase().includes(s) &&
|
||||||
|
!a.email.toLowerCase().includes(s) &&
|
||||||
|
!a.ticket_id.toLowerCase().includes(s)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkIn(attendee) {
|
||||||
|
try {
|
||||||
|
const result = await api.attendees.checkIn(attendee.id)
|
||||||
|
if (result.attendee) await db.attendees.put(result.attendee)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAttendee(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
adding = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const a = await api.attendees.create({
|
||||||
|
name: newName, email: newEmail, phone: newPhone,
|
||||||
|
ticket_id: newTicketID, ticket_type: newTicketType, note: newNote,
|
||||||
|
})
|
||||||
|
await db.attendees.put(a)
|
||||||
|
showAdd = false
|
||||||
|
newName = newEmail = newPhone = newTicketID = newTicketType = newNote = ''
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTokens() {
|
||||||
|
generating = true
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
const result = await api.attendees.generateTokens()
|
||||||
|
success = `Generated ${result.generated} token${result.generated !== 1 ? 's' : ''}.`
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
generating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailAll() {
|
||||||
|
if (!confirm('Send token emails to all attendees with a token and email address?')) return
|
||||||
|
emailing = true
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
const result = await api.attendees.emailAllTokens()
|
||||||
|
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||||
|
if (result.errors?.length) error = result.errors.join('; ')
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
emailing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailToken(attendee) {
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
await api.attendees.emailToken(attendee.id)
|
||||||
|
success = `Token email sent to ${attendee.name}.`
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Attendees</h1>
|
||||||
|
<div class="actions">
|
||||||
|
{#if canManage}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||||
|
<a href="/api/attendees/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={generateTokens} disabled={generating}>
|
||||||
|
{generating ? '…' : '⚿ Tokens'}
|
||||||
|
</button>
|
||||||
|
<a href="/api/attendees/export-tokens" class="btn btn-ghost btn-sm">Export Links</a>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||||
|
{emailing ? '…' : '✉ Email All'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if success}
|
||||||
|
<div class="alert alert-success">{success}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAdd && canManage}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={addAttendee}>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-name">Name *</label>
|
||||||
|
<input id="new-name" bind:value={newName} required placeholder="Full name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-email">Email</label>
|
||||||
|
<input id="new-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-ticket-id">Ticket ID</label>
|
||||||
|
<input id="new-ticket-id" bind:value={newTicketID} placeholder="From ticketing system" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-ticket-type">Ticket type</label>
|
||||||
|
<input id="new-ticket-type" bind:value={newTicketType} placeholder="e.g. General, VIP" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-note">Note</label>
|
||||||
|
<input id="new-note" bind:value={newNote} placeholder="Optional note" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||||
|
{adding ? 'Adding…' : 'Add attendee'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input placeholder="Search name, email, ticket ID…" bind:value={search} />
|
||||||
|
{#if ($ticketTypes ?? []).length > 0}
|
||||||
|
<select bind:value={filterType} style="width:auto">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{#each $ticketTypes ?? [] as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<select bind:value={filterChecked} style="width:auto">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="false">Not checked in</option>
|
||||||
|
<option value="true">Checked in</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||||
|
{filtered.length} shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ($allAttendees ?? []).length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No attendees yet</strong>
|
||||||
|
<p>Import a CSV or add attendees manually.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Ticket type</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{#if canCheckIn}<th></th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filtered as a (a.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{a.name}</strong>
|
||||||
|
{#if a.ticket_id}
|
||||||
|
<span class="text-muted" style="font-size:0.8rem"> · {a.ticket_id}</span>
|
||||||
|
{/if}
|
||||||
|
{#if (a.party_size ?? 1) > 1}
|
||||||
|
<span class="badge badge-lead" style="margin-left:0.3rem">×{a.party_size}</span>
|
||||||
|
{/if}
|
||||||
|
{#if a.note}
|
||||||
|
<div class="text-muted" style="font-size:0.78rem">{a.note}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{a.ticket_type || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div>{a.email || '—'}</div>
|
||||||
|
{#if a.volunteer_token && canManage}
|
||||||
|
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||||
|
<code style="color:var(--c-accent-h)">{a.volunteer_token}</code>
|
||||||
|
{#if a.email}
|
||||||
|
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||||
|
onclick={() => emailToken(a)}>✉</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if (a.party_size ?? 1) > 1}
|
||||||
|
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
|
{a.checked_in_count ?? 0}/{a.party_size} in
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
|
{a.checked_in ? 'Checked in' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if a.checked_in_at}
|
||||||
|
<div class="text-muted" style="font-size:0.75rem">
|
||||||
|
{new Date(a.checked_in_at).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{#if canCheckIn}
|
||||||
|
<td>
|
||||||
|
{#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)}
|
||||||
|
<CheckInButton onclick={() => checkIn(a)} />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
62
frontend/src/pages/Dashboard.svelte
Normal file
62
frontend/src/pages/Dashboard.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
const attendees = liveQuery(() => db.attendees.toArray())
|
||||||
|
const event = liveQuery(() => db.event.get(1))
|
||||||
|
|
||||||
|
const total = $derived(($attendees ?? []).length)
|
||||||
|
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
|
||||||
|
const remaining = $derived(total - checkedIn)
|
||||||
|
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">{$event?.name ?? 'Event'}</h1>
|
||||||
|
{#if $event?.venue}
|
||||||
|
<span class="text-muted">{$event.venue}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $event?.start_date}
|
||||||
|
<p class="text-muted" style="margin-bottom:1.5rem">
|
||||||
|
{$event.start_date}{$event.end_date !== $event.start_date ? ` – ${$event.end_date}` : ''}
|
||||||
|
{#if $event.timezone} · {$event.timezone}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
<div class="stat-value">{total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Checked in</div>
|
||||||
|
<div class="stat-value" style="color:var(--c-success)">{checkedIn}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Remaining</div>
|
||||||
|
<div class="stat-value">{remaining}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Progress</div>
|
||||||
|
<div class="stat-value">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if total > 0}
|
||||||
|
<div class="card" style="margin-bottom:1rem">
|
||||||
|
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden">
|
||||||
|
<div style="height:100%;width:{pct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:0.85rem">
|
||||||
|
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||||
|
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
190
frontend/src/pages/Departments.svelte
Normal file
190
frontend/src/pages/Departments.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let error = $state('')
|
||||||
|
let showAdd = $state(false)
|
||||||
|
let adding = $state(false)
|
||||||
|
let newName = $state('')
|
||||||
|
let newColor = $state('#6366f1')
|
||||||
|
let newDesc = $state('')
|
||||||
|
|
||||||
|
let editID = $state(null)
|
||||||
|
let editName = $state('')
|
||||||
|
let editColor = $state('#6366f1')
|
||||||
|
let editDesc = $state('')
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const canCreate = $derived(['admin', 'coordinator'].includes(role))
|
||||||
|
const canDelete = $derived(role === 'admin')
|
||||||
|
|
||||||
|
const allDepts = liveQuery(() =>
|
||||||
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addDept(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
adding = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const d = await api.departments.create({ name: newName, color: newColor, description: newDesc })
|
||||||
|
await db.departments.put(d)
|
||||||
|
showAdd = false
|
||||||
|
newName = newDesc = ''
|
||||||
|
newColor = '#6366f1'
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(d) {
|
||||||
|
editID = d.id
|
||||||
|
editName = d.name
|
||||||
|
editColor = d.color || '#6366f1'
|
||||||
|
editDesc = d.description || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDept(d) {
|
||||||
|
if (!editName.trim()) return
|
||||||
|
saving = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const updated = await api.departments.update(d.id, {
|
||||||
|
name: editName, color: editColor, description: editDesc,
|
||||||
|
})
|
||||||
|
await db.departments.put(updated)
|
||||||
|
editID = null
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDept(d) {
|
||||||
|
if (!confirm(`Delete department "${d.name}"? Volunteers in this department will be unassigned.`)) return
|
||||||
|
try {
|
||||||
|
await api.departments.delete(d.id)
|
||||||
|
await db.departments.delete(d.id)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Departments</h1>
|
||||||
|
{#if canCreate}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAdd && canCreate}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={addDept}>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label for="d-name">Name *</label>
|
||||||
|
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label for="d-desc">Description</label>
|
||||||
|
<input id="d-desc" bind:value={newDesc} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label for="d-color">Color</label>
|
||||||
|
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||||
|
{adding ? 'Adding…' : 'Add department'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if ($allDepts ?? []).length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No departments yet</strong>
|
||||||
|
<p>Add departments to organize your volunteer teams.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Description</th>
|
||||||
|
{#if canCreate}<th></th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $allDepts ?? [] as d (d.id)}
|
||||||
|
{#if editID === d.id}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||||
|
<input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" />
|
||||||
|
<input bind:value={editName} required placeholder="Name" style="margin:0" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
||||||
|
</td>
|
||||||
|
{#if canCreate}
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
||||||
|
<strong>{d.name}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{d.description || '—'}</td>
|
||||||
|
{#if canCreate}
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
||||||
|
{#if canDelete}
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => deleteDept(d)}>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
451
frontend/src/pages/GateUI.svelte
Normal file
451
frontend/src/pages/GateUI.svelte
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let { session, onLogout } = $props()
|
||||||
|
|
||||||
|
let search = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let scannerMsg = $state('')
|
||||||
|
let qrSupported = $state(false)
|
||||||
|
let scanning = $state(false)
|
||||||
|
let videoRef = $state(null)
|
||||||
|
let stream = $state(null)
|
||||||
|
let detector = $state(null)
|
||||||
|
let scanInterval = $state(null)
|
||||||
|
|
||||||
|
const attendees = liveQuery(() =>
|
||||||
|
db.attendees.filter(a => !a.deleted_at).toArray()
|
||||||
|
)
|
||||||
|
|
||||||
|
const recentCheckIns = liveQuery(() =>
|
||||||
|
db.attendees
|
||||||
|
.filter(a => a.checked_in && !a.deleted_at)
|
||||||
|
.toArray()
|
||||||
|
.then(arr => arr
|
||||||
|
.filter(a => a.checked_in_at)
|
||||||
|
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
||||||
|
.slice(0, 10)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const s = search.trim().toLowerCase()
|
||||||
|
if (!s || s.length < 2) return []
|
||||||
|
return ($attendees ?? [])
|
||||||
|
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 8)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected = $derived.by(() => {
|
||||||
|
if (filtered.length === 1) return filtered[0]
|
||||||
|
const s = search.trim().toLowerCase()
|
||||||
|
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
qrSupported = 'BarcodeDetector' in window
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopScanner()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggleScanner() {
|
||||||
|
if (scanning) {
|
||||||
|
stopScanner()
|
||||||
|
} else {
|
||||||
|
await startScanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanner() {
|
||||||
|
scannerMsg = ''
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
|
||||||
|
if (videoRef) videoRef.srcObject = stream
|
||||||
|
detector = new BarcodeDetector({ formats: ['qr_code'] })
|
||||||
|
scanning = true
|
||||||
|
scanInterval = setInterval(doScan, 150)
|
||||||
|
} catch (err) {
|
||||||
|
scannerMsg = 'Camera access denied or unavailable.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
clearInterval(scanInterval)
|
||||||
|
scanInterval = null
|
||||||
|
stream?.getTracks().forEach(t => t.stop())
|
||||||
|
stream = null
|
||||||
|
scanning = false
|
||||||
|
if (videoRef) videoRef.srcObject = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doScan() {
|
||||||
|
if (!detector || !videoRef || videoRef.readyState < 2) return
|
||||||
|
try {
|
||||||
|
const codes = await detector.detect(videoRef)
|
||||||
|
if (codes.length > 0) {
|
||||||
|
const val = codes[0].rawValue
|
||||||
|
search = val
|
||||||
|
stopScanner()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkIn(attendee, count = 1) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await api.attendees.checkIn(attendee.id, { count })
|
||||||
|
if (result.attendee) {
|
||||||
|
await db.attendees.put(result.attendee)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkInWithVolunteer(attendee) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true })
|
||||||
|
if (result.attendee) await db.attendees.put(result.attendee)
|
||||||
|
if (result.volunteer) await db.volunteers.put(result.volunteer)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remaining(a) {
|
||||||
|
return (a.party_size ?? 1) - (a.checked_in_count ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressLabel(a) {
|
||||||
|
const ps = a.party_size ?? 1
|
||||||
|
const ci = a.checked_in_count ?? 0
|
||||||
|
if (ps <= 1) return null
|
||||||
|
return `${ci}/${ps} checked in`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="gate">
|
||||||
|
<div class="gate-header">
|
||||||
|
<div class="gate-brand">Turn<span>pike</span> <span class="gate-role">Gate Check-in</span></div>
|
||||||
|
<button class="gate-logout" onclick={onLogout}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gate-body">
|
||||||
|
<!-- Search + QR -->
|
||||||
|
<div class="gate-search-row">
|
||||||
|
<input
|
||||||
|
class="gate-search"
|
||||||
|
placeholder="Search name, ticket ID, or scan QR…"
|
||||||
|
bind:value={search}
|
||||||
|
/>
|
||||||
|
{#if qrSupported}
|
||||||
|
<button class="gbtn {scanning ? 'gbtn-danger' : 'gbtn-ghost'}" onclick={toggleScanner}>
|
||||||
|
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if scanning}
|
||||||
|
<div class="gate-scanner">
|
||||||
|
<video bind:this={videoRef} autoplay playsinline muted class="gate-video"></video>
|
||||||
|
<div class="gate-scanner-hint">Point camera at QR code on ticket</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if scannerMsg}
|
||||||
|
<div class="gate-msg gate-msg-warn">{scannerMsg}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="gate-msg gate-msg-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Matched attendee card -->
|
||||||
|
{#if selected}
|
||||||
|
{@const rem = remaining(selected)}
|
||||||
|
{@const prog = progressLabel(selected)}
|
||||||
|
<div class="gate-match">
|
||||||
|
<div class="gate-match-name">{selected.name}</div>
|
||||||
|
{#if selected.ticket_type}
|
||||||
|
<div class="gate-match-sub">{selected.ticket_type}</div>
|
||||||
|
{/if}
|
||||||
|
{#if selected.ticket_id}
|
||||||
|
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
|
||||||
|
{/if}
|
||||||
|
{#if prog}
|
||||||
|
<div class="gate-party">
|
||||||
|
<span class="gate-party-label">{prog}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="gate-match-actions">
|
||||||
|
{#if rem > 0}
|
||||||
|
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
|
||||||
|
✓ Check in 1
|
||||||
|
</button>
|
||||||
|
{#if rem > 1}
|
||||||
|
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
|
||||||
|
Check in all {rem}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="gate-done">All checked in</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selected.volunteer_token && !selected.checked_in}
|
||||||
|
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
|
||||||
|
+ Volunteer
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if search.trim().length >= 2 && filtered.length > 1}
|
||||||
|
<!-- Multiple results list -->
|
||||||
|
<div class="gate-results">
|
||||||
|
{#each filtered as a}
|
||||||
|
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
|
||||||
|
<span>
|
||||||
|
<strong>{a.name}</strong>
|
||||||
|
{#if a.ticket_type} · {a.ticket_type}{/if}
|
||||||
|
</span>
|
||||||
|
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
|
{a.checked_in ? 'In' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if search.trim().length >= 2 && filtered.length === 0}
|
||||||
|
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent check-ins -->
|
||||||
|
<div class="gate-recent">
|
||||||
|
<div class="gate-recent-title">Recent Check-ins</div>
|
||||||
|
{#if ($recentCheckIns ?? []).length === 0}
|
||||||
|
<div class="gate-recent-empty">No check-ins yet today.</div>
|
||||||
|
{:else}
|
||||||
|
{#each $recentCheckIns ?? [] as a}
|
||||||
|
<div class="gate-recent-row">
|
||||||
|
<span>{a.name}</span>
|
||||||
|
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gate {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.gate-header {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.gate-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.gate-brand span:first-of-type { color: var(--c-accent); }
|
||||||
|
.gate-role {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c-muted);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.gate-logout {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.gate-logout:hover { color: var(--c-text); background: rgba(255,255,255,0.05); }
|
||||||
|
|
||||||
|
.gate-body {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.gate-search {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.gate-search:focus { outline: none; border-color: var(--c-accent); }
|
||||||
|
|
||||||
|
.gate-scanner {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.gate-video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
max-height: 280px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.gate-scanner-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-msg {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.gate-msg-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
|
||||||
|
.gate-msg-warn { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); color: var(--c-warn); }
|
||||||
|
|
||||||
|
.gate-match {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
|
||||||
|
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
||||||
|
.gate-party {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.gate-party-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-warn);
|
||||||
|
background: rgba(245,158,11,0.15);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
.gate-match-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.gate-done {
|
||||||
|
color: var(--c-success);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-results {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.gate-result-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.gate-result-row:last-child { border-bottom: none; }
|
||||||
|
.gate-result-row:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
|
||||||
|
.gate-recent { margin-top: 2rem; }
|
||||||
|
.gate-recent-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--c-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.gate-recent-empty { color: var(--c-muted); font-size: 0.875rem; }
|
||||||
|
.gate-recent-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.gate-recent-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.gbtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem; border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||||
|
font-family: var(--font); white-space: nowrap;
|
||||||
|
transition: background 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.gbtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.gbtn-success { background: var(--c-success); color: #000; font-weight: 600; }
|
||||||
|
.gbtn-success:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.gbtn-danger { background: var(--c-danger); color: #fff; }
|
||||||
|
.gbtn-danger:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.gbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
|
||||||
|
.gbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.gate-match-name { font-size: 1.2rem; }
|
||||||
|
.gate-search { font-size: 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
frontend/src/pages/Import.svelte
Normal file
96
frontend/src/pages/Import.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script>
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import { syncPull } from '../sync.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let file = $state(null)
|
||||||
|
let importing = $state(false)
|
||||||
|
let result = $state(null)
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
async function doImport(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!file) return
|
||||||
|
importing = true
|
||||||
|
error = ''
|
||||||
|
result = null
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('csv', file)
|
||||||
|
result = await api.import(fd)
|
||||||
|
// Pull to sync new attendees into Dexie
|
||||||
|
await syncPull()
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
importing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e) {
|
||||||
|
file = e.target.files[0] ?? null
|
||||||
|
result = null
|
||||||
|
error = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Import</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:540px;margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={doImport}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="csv-file">CSV file</label>
|
||||||
|
<input id="csv-file" type="file" accept=".csv,text/csv"
|
||||||
|
onchange={onFileChange}
|
||||||
|
style="padding:0.4rem 0;border:none;background:transparent;color:var(--c-text)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.82rem;color:var(--c-muted);margin-bottom:1rem;line-height:1.6">
|
||||||
|
<strong style="color:var(--c-text)">Supported formats:</strong><br>
|
||||||
|
<strong>CrowdWork / ticketing platform:</strong> columns <code>Patron Name</code>, <code>Patron Email</code>, <code>Tier Name</code>, <code>Order Number</code><br>
|
||||||
|
<strong>Generic:</strong> columns <code>name</code>, <code>email</code>, <code>ticket_id</code>, <code>ticket_type</code>, <code>note</code><br>
|
||||||
|
Duplicate names are skipped.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
||||||
|
{importing ? 'Importing…' : 'Import'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<div class="card" style="max-width:540px">
|
||||||
|
<div style="display:flex;gap:2rem;margin-bottom:{result.errors?.length ? '1rem' : '0'}">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Imported</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:var(--c-success)">{result.inserted}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Skipped</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:var(--c-muted)">{result.skipped}</div>
|
||||||
|
</div>
|
||||||
|
{#if result.errors?.length}
|
||||||
|
<div>
|
||||||
|
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Errors</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:var(--c-danger)">{result.errors.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if result.errors?.length}
|
||||||
|
<div style="font-size:0.82rem;color:var(--c-muted)">
|
||||||
|
{#each result.errors as err}
|
||||||
|
<div style="padding:0.2rem 0">{err}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
356
frontend/src/pages/Kiosk.svelte
Normal file
356
frontend/src/pages/Kiosk.svelte
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
// Token comes from the URL hash: /#/v/TOKEN
|
||||||
|
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||||
|
|
||||||
|
let state = $state(null) // { volunteer, shifts, available }
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
// Conflict dialog state
|
||||||
|
let conflictShift = $state(null)
|
||||||
|
let conflictingShifts = $state([])
|
||||||
|
let claiming = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (token) loadState()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
loading = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
state = await api.kiosk.get(token)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claim(shift) {
|
||||||
|
claiming = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await api.kiosk.claim(token, shift.id)
|
||||||
|
if (result.conflict) {
|
||||||
|
conflictShift = shift
|
||||||
|
conflictingShifts = result.conflicting_shifts ?? []
|
||||||
|
claiming = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = result
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
claiming = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimForce() {
|
||||||
|
if (!conflictShift) return
|
||||||
|
claiming = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await api.kiosk.claim(token, conflictShift.id, true)
|
||||||
|
if (!result.conflict) {
|
||||||
|
state = result
|
||||||
|
conflictShift = null
|
||||||
|
conflictingShifts = []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
claiming = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unclaim(shiftId) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
state = await api.kiosk.unclaim(token, shiftId)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
const ampm = h < 12 ? 'am' : 'pm'
|
||||||
|
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDay(shifts) {
|
||||||
|
const days = {}
|
||||||
|
for (const s of shifts) {
|
||||||
|
if (!days[s.day]) days[s.day] = []
|
||||||
|
days[s.day].push(s)
|
||||||
|
}
|
||||||
|
return Object.entries(days).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAssigned = $derived((shiftId) =>
|
||||||
|
state?.shifts?.some(s => s.id === shiftId) ?? false
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="kiosk-header">
|
||||||
|
<div class="kiosk-brand">Turn<span>pike</span> <span class="kiosk-role">Volunteer Portal</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !token}
|
||||||
|
<div class="kiosk-body">
|
||||||
|
<div class="kiosk-error">No volunteer token found in URL.<br>Check the link you were sent.</div>
|
||||||
|
</div>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="kiosk-body kiosk-center">Loading…</div>
|
||||||
|
{:else if error && !state}
|
||||||
|
<div class="kiosk-body">
|
||||||
|
<div class="kiosk-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
{:else if state}
|
||||||
|
<div class="kiosk-body">
|
||||||
|
{#if error}
|
||||||
|
<div class="kiosk-alert">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Conflict dialog -->
|
||||||
|
{#if conflictShift}
|
||||||
|
<div class="kiosk-overlay">
|
||||||
|
<div class="kiosk-dialog">
|
||||||
|
<h3>Scheduling Conflict</h3>
|
||||||
|
<p>
|
||||||
|
<strong>{conflictShift.name}</strong> ({fmt(conflictShift.start_time)}–{fmt(conflictShift.end_time)})
|
||||||
|
overlaps with:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{#each conflictingShifts as s}
|
||||||
|
<li>{s.name} — {fmt(s.start_time)}–{fmt(s.end_time)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="kiosk-muted">You can still sign up — just confirm you're aware of the overlap.</p>
|
||||||
|
<div class="kiosk-actions">
|
||||||
|
<button class="kbtn kbtn-primary" onclick={claimForce} disabled={claiming}>
|
||||||
|
{claiming ? '…' : 'Sign up anyway'}
|
||||||
|
</button>
|
||||||
|
<button class="kbtn kbtn-ghost" onclick={() => { conflictShift = null; conflictingShifts = [] }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Volunteer header -->
|
||||||
|
<div class="kiosk-card">
|
||||||
|
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
||||||
|
<div class="kiosk-vol-meta">
|
||||||
|
{state.volunteer.email || ''}
|
||||||
|
{state.volunteer.is_lead ? ' · Department Lead' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="kiosk-token">Token: <code>{token}</code></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assigned shifts -->
|
||||||
|
{#if state.shifts.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="kiosk-section-title">My Shifts</h2>
|
||||||
|
{#each groupByDay(state.shifts) as [day, shifts]}
|
||||||
|
<div class="kiosk-day-label">{day}</div>
|
||||||
|
{#each shifts as s}
|
||||||
|
<div class="kiosk-shift-card kiosk-shift-assigned">
|
||||||
|
<div class="kiosk-shift-info">
|
||||||
|
<strong>{s.name}</strong>
|
||||||
|
<span class="kiosk-time">{fmt(s.start_time)} – {fmt(s.end_time)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="kbtn kbtn-ghost kbtn-sm" onclick={() => unclaim(s.id)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<div class="kiosk-empty">You haven't signed up for any shifts yet.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Available shifts -->
|
||||||
|
{#if state.available.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="kiosk-section-title">Available Shifts</h2>
|
||||||
|
{#each groupByDay(state.available) as [day, shifts]}
|
||||||
|
<div class="kiosk-day-label">{day}</div>
|
||||||
|
{#each shifts as s}
|
||||||
|
{@const assigned = isAssigned(s.id)}
|
||||||
|
{#if !assigned}
|
||||||
|
<div class="kiosk-shift-card">
|
||||||
|
<div class="kiosk-shift-info">
|
||||||
|
<strong>{s.name}</strong>
|
||||||
|
<span class="kiosk-time">{fmt(s.start_time)} – {fmt(s.end_time)}</span>
|
||||||
|
{#if s.capacity > 0}
|
||||||
|
<span class="kiosk-cap">
|
||||||
|
{s.capacity} spots
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="kbtn kbtn-primary kbtn-sm" onclick={() => claim(s)} disabled={claiming}>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{:else if state.shifts.length === 0}
|
||||||
|
<div class="kiosk-empty">No shifts are currently available in your department.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kiosk {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kiosk-header {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.kiosk-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.kiosk-brand span:first-of-type { color: var(--c-accent); }
|
||||||
|
.kiosk-role {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c-muted);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.kiosk-body {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kiosk-center { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.kiosk-error {
|
||||||
|
background: rgba(239,68,68,0.12);
|
||||||
|
border: 1px solid rgba(239,68,68,0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.kiosk-alert {
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
border: 1px solid rgba(239,68,68,0.25);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.kiosk-card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.kiosk-vol-name { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.2rem; }
|
||||||
|
.kiosk-vol-meta { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 0.5rem; }
|
||||||
|
.kiosk-token { font-size: 0.78rem; color: var(--c-muted); }
|
||||||
|
.kiosk-token code {
|
||||||
|
background: rgba(99,102,241,0.15);
|
||||||
|
color: var(--c-accent-h);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.kiosk-section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--c-muted);
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.kiosk-day-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--c-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
.kiosk-shift-card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.kiosk-shift-assigned { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.kiosk-shift-info { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.kiosk-shift-info strong { font-size: 0.9rem; }
|
||||||
|
.kiosk-time { font-size: 0.8rem; color: var(--c-muted); }
|
||||||
|
.kiosk-cap { font-size: 0.75rem; color: var(--c-muted); }
|
||||||
|
.kiosk-empty { color: var(--c-muted); font-size: 0.875rem; padding: 1rem 0; }
|
||||||
|
|
||||||
|
.kbtn {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 0.45rem 1rem; border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: background 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.kbtn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||||
|
.kbtn-primary { background: var(--c-accent); color: #fff; }
|
||||||
|
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
|
||||||
|
.kbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
|
||||||
|
.kbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
|
||||||
|
|
||||||
|
.kiosk-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.kiosk-dialog {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.kiosk-dialog h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.75rem; }
|
||||||
|
.kiosk-dialog p { font-size: 0.875rem; margin-bottom: 0.75rem; line-height: 1.6; }
|
||||||
|
.kiosk-dialog ul { margin: 0.5rem 0 0.75rem 1.25rem; font-size: 0.875rem; line-height: 1.7; color: var(--c-muted); }
|
||||||
|
.kiosk-muted { color: var(--c-muted) !important; }
|
||||||
|
.kiosk-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
49
frontend/src/pages/Login.svelte
Normal file
49
frontend/src/pages/Login.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script>
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import { saveSession } from '../db.js'
|
||||||
|
|
||||||
|
let { onlogin } = $props()
|
||||||
|
|
||||||
|
let username = $state('')
|
||||||
|
let password = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
error = ''
|
||||||
|
loading = true
|
||||||
|
try {
|
||||||
|
const { token, user } = await api.login(username, password)
|
||||||
|
await saveSession(token, user)
|
||||||
|
onlogin({ token, user })
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message || 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1 class="login-title">Turnpike</h1>
|
||||||
|
<p class="login-sub">Event management</p>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" bind:value={username} autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" bind:value={password} autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%" disabled={loading}>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
452
frontend/src/pages/ScheduleBoard.svelte
Normal file
452
frontend/src/pages/ScheduleBoard.svelte
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let error = $state('')
|
||||||
|
let editShiftID = $state(null)
|
||||||
|
let editShift = $state({})
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
|
// For volunteer assignment dropdown
|
||||||
|
let assigningShiftID = $state(null)
|
||||||
|
let assignVolID = $state(0)
|
||||||
|
let assigning = $state(false)
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
|
const allDepts = liveQuery(() =>
|
||||||
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allShifts = liveQuery(() =>
|
||||||
|
db.shifts.filter(s => !s.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) =>
|
||||||
|
(a.day === b.day ? (a.position - b.position || a.start_time.localeCompare(b.start_time))
|
||||||
|
: a.day.localeCompare(b.day))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allVolunteers = liveQuery(() =>
|
||||||
|
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allVolunteerShifts = liveQuery(() =>
|
||||||
|
db.volunteer_shifts.toArray()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Departments visible to this user
|
||||||
|
const visibleDepts = $derived.by(() => {
|
||||||
|
const depts = $allDepts ?? []
|
||||||
|
if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id))
|
||||||
|
return depts
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grouped structure: dept → days → shifts
|
||||||
|
const board = $derived.by(() => {
|
||||||
|
const shifts = $allShifts ?? []
|
||||||
|
const vols = $allVolunteers ?? []
|
||||||
|
const vss = $allVolunteerShifts ?? []
|
||||||
|
|
||||||
|
return visibleDepts.map(dept => {
|
||||||
|
const deptShifts = shifts.filter(s => s.department_id === dept.id)
|
||||||
|
const days = {}
|
||||||
|
for (const s of deptShifts) {
|
||||||
|
if (!days[s.day]) days[s.day] = []
|
||||||
|
const assigned = vss.filter(vs => vs.shift_id === s.id).map(vs => ({
|
||||||
|
vs,
|
||||||
|
volunteer: vols.find(v => v.id === vs.volunteer_id),
|
||||||
|
})).filter(x => x.volunteer)
|
||||||
|
const hasConflict = assigned.some(({ volunteer }) =>
|
||||||
|
checkConflict(volunteer.id, s.id, vss, shifts)
|
||||||
|
)
|
||||||
|
days[s.day].push({ shift: s, assigned, hasConflict })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dept,
|
||||||
|
days: Object.entries(days).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkConflict(volunteerID, shiftID, vss, shifts) {
|
||||||
|
const target = shifts.find(s => s.id === shiftID)
|
||||||
|
if (!target) return false
|
||||||
|
return vss
|
||||||
|
.filter(vs => vs.volunteer_id === volunteerID && vs.shift_id !== shiftID)
|
||||||
|
.some(vs => {
|
||||||
|
const s = shifts.find(sh => sh.id === vs.shift_id)
|
||||||
|
return s && s.day === target.day &&
|
||||||
|
s.start_time < target.end_time &&
|
||||||
|
target.start_time < s.end_time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(s) {
|
||||||
|
editShiftID = s.id
|
||||||
|
editShift = { ...s }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editShiftID = null
|
||||||
|
editShift = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveShift() {
|
||||||
|
saving = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
await api.shifts.update(editShiftID, editShift)
|
||||||
|
await db.shifts.put({ ...editShift, id: editShiftID })
|
||||||
|
cancelEdit()
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorder(shiftId, delta, siblings) {
|
||||||
|
const idx = siblings.findIndex(({ shift }) => shift.id === shiftId)
|
||||||
|
const newIdx = idx + delta
|
||||||
|
if (newIdx < 0 || newIdx >= siblings.length) return
|
||||||
|
|
||||||
|
// Swap in a copy, then assign sequential positions 0, 1, 2, …
|
||||||
|
const reordered = [...siblings]
|
||||||
|
;[reordered[idx], reordered[newIdx]] = [reordered[newIdx], reordered[idx]]
|
||||||
|
const positions = reordered.map(({ shift }, i) => ({ id: shift.id, position: i }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.shifts.reorder(positions)
|
||||||
|
if (res && !res.ok) throw new Error()
|
||||||
|
for (const p of positions) {
|
||||||
|
const s = await db.shifts.get(p.id)
|
||||||
|
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAssign(shiftId) {
|
||||||
|
assigningShiftID = shiftId
|
||||||
|
assignVolID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAssign(shiftId) {
|
||||||
|
if (!assignVolID) return
|
||||||
|
assigning = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const res = await api.shifts.assignVolunteer(shiftId, assignVolID)
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
error = `Conflict: ${body.conflicting_shifts?.map(s => s.name).join(', ') ?? 'scheduling conflict'} — check the volunteer's other shifts.`
|
||||||
|
return
|
||||||
|
} else if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
error = body.error || 'Assignment failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await db.volunteer_shifts.put({ volunteer_id: assignVolID, shift_id: shiftId, confirmed: true, updated_at: new Date().toISOString() })
|
||||||
|
assigningShiftID = null
|
||||||
|
assignVolID = 0
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
assigning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAssignForce(shiftId) {
|
||||||
|
if (!assignVolID) return
|
||||||
|
assigning = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const res = await api.shifts.assignVolunteer(shiftId, assignVolID, true)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
error = body.error || 'Assignment failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await db.volunteer_shifts.put({ volunteer_id: assignVolID, shift_id: shiftId, confirmed: true, updated_at: new Date().toISOString() })
|
||||||
|
assigningShiftID = null
|
||||||
|
assignVolID = 0
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
assigning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unassign(shiftId, volunteerId) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
await api.shifts.unassignVolunteer(shiftId, volunteerId)
|
||||||
|
await db.volunteer_shifts.delete([volunteerId, shiftId])
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
const ampm = h < 12 ? 'am' : 'pm'
|
||||||
|
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Schedule Board</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if ($allShifts ?? []).length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No shifts yet</strong>
|
||||||
|
<p>Create shifts in the Shifts page first.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each board as { dept, days }}
|
||||||
|
{#if days.length > 0}
|
||||||
|
<div class="board-dept">
|
||||||
|
<div class="board-dept-header">
|
||||||
|
<span class="dept-dot" style="background:{dept.color}"></span>
|
||||||
|
{dept.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each days as [day, rows]}
|
||||||
|
<div class="board-day-label">{day}</div>
|
||||||
|
|
||||||
|
{#each rows as { shift, assigned, hasConflict }, i}
|
||||||
|
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
||||||
|
{#if editShiftID === shift.id}
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<div class="board-edit-form">
|
||||||
|
<div class="board-edit-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="es-name">Name</label>
|
||||||
|
<input id="es-name" bind:value={editShift.name} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="es-day">Day</label>
|
||||||
|
<input id="es-day" bind:value={editShift.day} placeholder="YYYY-MM-DD" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="es-start">Start</label>
|
||||||
|
<input id="es-start" type="time" bind:value={editShift.start_time} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="es-end">End</label>
|
||||||
|
<input id="es-end" type="time" bind:value={editShift.end_time} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="es-cap">Capacity</label>
|
||||||
|
<input id="es-cap" type="number" min="0" bind:value={editShift.capacity} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={saveShift} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="board-shift-main">
|
||||||
|
<div class="board-shift-meta">
|
||||||
|
<strong>{shift.name}</strong>
|
||||||
|
<span class="text-muted">{fmt(shift.start_time)}–{fmt(shift.end_time)}</span>
|
||||||
|
{#if shift.capacity > 0}
|
||||||
|
<span class="board-cap {assigned.length >= shift.capacity ? 'board-cap-full' : ''}">
|
||||||
|
{assigned.length}/{shift.capacity}
|
||||||
|
</span>
|
||||||
|
{:else if assigned.length > 0}
|
||||||
|
<span class="board-cap">{assigned.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if hasConflict}
|
||||||
|
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-shift-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" title="Move up"
|
||||||
|
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||||
|
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assigned volunteers -->
|
||||||
|
{#if assigned.length > 0}
|
||||||
|
<div class="board-volunteers">
|
||||||
|
{#each assigned as { vs, volunteer }}
|
||||||
|
<div class="board-vol-chip">
|
||||||
|
{volunteer.name}
|
||||||
|
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||||
|
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||||
|
{/if}
|
||||||
|
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Assign volunteer -->
|
||||||
|
{#if assigningShiftID === shift.id}
|
||||||
|
<div class="board-assign-row">
|
||||||
|
<select bind:value={assignVolID} style="width:auto">
|
||||||
|
<option value={0}>— Select volunteer —</option>
|
||||||
|
{#each $allVolunteers ?? [] as v}
|
||||||
|
<option value={v.id}>{v.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => doAssign(shift.id)} disabled={!assignVolID || assigning}>
|
||||||
|
{assigning ? '…' : 'Assign'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => doAssignForce(shift.id)} disabled={!assignVolID || assigning} title="Assign ignoring conflicts">
|
||||||
|
Force
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => { assigningShiftID = null; assignVolID = 0 }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board-dept {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.board-dept-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.board-day-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--c-muted);
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
.board-shift {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.board-shift-conflict {
|
||||||
|
border-color: rgba(245,158,11,0.4);
|
||||||
|
}
|
||||||
|
.board-shift-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.board-shift-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.board-shift-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.board-cap {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: rgba(99,102,241,0.12);
|
||||||
|
color: var(--c-accent-h);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
.board-cap-full {
|
||||||
|
background: rgba(239,68,68,0.12);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.board-volunteers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
.board-vol-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: rgba(99,102,241,0.12);
|
||||||
|
color: var(--c-accent-h);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.board-vol-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
.board-vol-remove:hover { color: var(--c-danger); }
|
||||||
|
.board-add-vol {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.board-add-vol:hover { color: var(--c-text); }
|
||||||
|
.board-assign-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.board-edit-form {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.board-edit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
frontend/src/pages/Settings.svelte
Normal file
151
frontend/src/pages/Settings.svelte
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let loading = $state(true)
|
||||||
|
let saving = $state(false)
|
||||||
|
let testing = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
let success = $state('')
|
||||||
|
|
||||||
|
let smtpHost = $state('')
|
||||||
|
let smtpPort = $state(587)
|
||||||
|
let smtpUser = $state('')
|
||||||
|
let smtpPassword = $state('')
|
||||||
|
let smtpFrom = $state('')
|
||||||
|
let smtpFromName = $state('')
|
||||||
|
let baseURL = $state('')
|
||||||
|
let testEmail = $state('')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const s = await api.settings.get()
|
||||||
|
smtpHost = s.smtp_host ?? ''
|
||||||
|
smtpPort = s.smtp_port ?? 587
|
||||||
|
smtpUser = s.smtp_user ?? ''
|
||||||
|
smtpPassword = '' // never pre-filled
|
||||||
|
smtpFrom = s.smtp_from ?? ''
|
||||||
|
smtpFromName = s.smtp_from_name ?? ''
|
||||||
|
baseURL = s.base_url ?? ''
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
saving = true
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
await api.settings.update({
|
||||||
|
smtp_host: smtpHost,
|
||||||
|
smtp_port: smtpPort,
|
||||||
|
smtp_user: smtpUser,
|
||||||
|
smtp_password: smtpPassword, // empty = keep existing
|
||||||
|
smtp_from: smtpFrom,
|
||||||
|
smtp_from_name: smtpFromName,
|
||||||
|
base_url: baseURL,
|
||||||
|
})
|
||||||
|
smtpPassword = ''
|
||||||
|
success = 'Settings saved.'
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest() {
|
||||||
|
if (!testEmail) return
|
||||||
|
testing = true
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
await api.settings.testEmail(testEmail)
|
||||||
|
success = `Test email sent to ${testEmail}.`
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if success}
|
||||||
|
<div class="alert alert-success">{success}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-muted">Loading…</div>
|
||||||
|
{:else}
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group" style="grid-column:1">
|
||||||
|
<label for="s-host">SMTP Host</label>
|
||||||
|
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-port">Port</label>
|
||||||
|
<input id="s-port" type="number" bind:value={smtpPort} placeholder="587" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-user">Username</label>
|
||||||
|
<input id="s-user" bind:value={smtpUser} placeholder="user@example.com" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-pass">Password</label>
|
||||||
|
<input id="s-pass" type="password" bind:value={smtpPassword}
|
||||||
|
placeholder="Leave blank to keep existing" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-from">From Address</label>
|
||||||
|
<input id="s-from" type="email" bind:value={smtpFrom} placeholder="events@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-fname">From Name</label>
|
||||||
|
<input id="s-fname" bind:value={smtpFromName} placeholder="Event Team" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label>
|
||||||
|
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Test email -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||||
|
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||||
|
<label for="s-test">Send to</label>
|
||||||
|
<input id="s-test" type="email" bind:value={testEmail} placeholder="your@email.com" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" onclick={sendTest} disabled={testing || !testEmail}>
|
||||||
|
{testing ? 'Sending…' : 'Send Test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
221
frontend/src/pages/Shifts.svelte
Normal file
221
frontend/src/pages/Shifts.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let error = $state('')
|
||||||
|
let showAdd = $state(false)
|
||||||
|
let adding = $state(false)
|
||||||
|
let newDeptID = $state('')
|
||||||
|
let newName = $state('')
|
||||||
|
let newDay = $state('')
|
||||||
|
let newStart = $state('')
|
||||||
|
let newEnd = $state('')
|
||||||
|
let newCapacity = $state(0)
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||||
|
|
||||||
|
const allShifts = liveQuery(() =>
|
||||||
|
db.shifts.filter(s => !s.deleted_at).toArray()
|
||||||
|
)
|
||||||
|
const allDepts = liveQuery(() =>
|
||||||
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group shifts by department, then by day
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
const shifts = $allShifts ?? []
|
||||||
|
const depts = $allDepts ?? []
|
||||||
|
|
||||||
|
const byDept = {}
|
||||||
|
for (const s of shifts) {
|
||||||
|
if (!byDept[s.department_id]) byDept[s.department_id] = {}
|
||||||
|
if (!byDept[s.department_id][s.day]) byDept[s.department_id][s.day] = []
|
||||||
|
byDept[s.department_id][s.day].push(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return depts
|
||||||
|
.filter(d => byDept[d.id])
|
||||||
|
.map(d => ({
|
||||||
|
dept: d,
|
||||||
|
days: Object.entries(byDept[d.id] || {})
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([day, dayShifts]) => ({
|
||||||
|
day,
|
||||||
|
shifts: [...dayShifts].sort((a, b) => a.start_time.localeCompare(b.start_time)),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shifts not yet in any department group (e.g. orphaned)
|
||||||
|
const ungrouped = $derived.by(() => {
|
||||||
|
const shifts = $allShifts ?? []
|
||||||
|
const deptIDs = new Set(($allDepts ?? []).map(d => d.id))
|
||||||
|
return shifts.filter(s => !deptIDs.has(s.department_id))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addShift(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newDeptID) return
|
||||||
|
adding = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const s = await api.shifts.create({
|
||||||
|
department_id: parseInt(newDeptID),
|
||||||
|
name: newName,
|
||||||
|
day: newDay,
|
||||||
|
start_time: newStart,
|
||||||
|
end_time: newEnd,
|
||||||
|
capacity: parseInt(newCapacity) || 0,
|
||||||
|
})
|
||||||
|
await db.shifts.put(s)
|
||||||
|
showAdd = false
|
||||||
|
newName = newDay = newStart = newEnd = ''
|
||||||
|
newDeptID = ''
|
||||||
|
newCapacity = 0
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteShift(s) {
|
||||||
|
if (!confirm(`Delete shift "${s.name}"?`)) return
|
||||||
|
try {
|
||||||
|
await api.shifts.delete(s.id)
|
||||||
|
await db.shifts.delete(s.id)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
// t is HH:MM, format nicely
|
||||||
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
const ampm = h >= 12 ? 'pm' : 'am'
|
||||||
|
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDay(d) {
|
||||||
|
if (!d) return ''
|
||||||
|
const dt = new Date(d + 'T00:00:00')
|
||||||
|
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Shifts</h1>
|
||||||
|
{#if canManage}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAdd && canManage}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={addShift}>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-dept">Department *</label>
|
||||||
|
<select id="s-dept" bind:value={newDeptID} required>
|
||||||
|
<option value="">Select department…</option>
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<option value={String(d.id)}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-name">Shift name *</label>
|
||||||
|
<input id="s-name" bind:value={newName} required placeholder="e.g. Gate Morning" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-day">Day *</label>
|
||||||
|
<input id="s-day" type="date" bind:value={newDay} required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-cap">Capacity <span class="text-muted">(0 = unlimited)</span></label>
|
||||||
|
<input id="s-cap" type="number" min="0" bind:value={newCapacity} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-start">Start time *</label>
|
||||||
|
<input id="s-start" type="time" bind:value={newStart} required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-end">End time *</label>
|
||||||
|
<input id="s-end" type="time" bind:value={newEnd} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||||
|
{adding ? 'Adding…' : 'Add shift'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if ($allShifts ?? []).length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No shifts yet</strong>
|
||||||
|
<p>Add shifts to schedule your volunteers.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as { dept, days }}
|
||||||
|
<div style="margin-bottom:2rem">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||||
|
<span class="dept-dot" style="background:{dept.color}"></span>
|
||||||
|
<strong style="font-size:1rem">{dept.name}</strong>
|
||||||
|
</div>
|
||||||
|
{#each days as { day, shifts }}
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem;padding-left:1rem">
|
||||||
|
{formatDay(day)}
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each shifts as s (s.id)}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{s.name}</strong></td>
|
||||||
|
<td class="text-muted">{formatTime(s.start_time)} – {formatTime(s.end_time)}</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{#if s.capacity}
|
||||||
|
Capacity: {s.capacity}
|
||||||
|
{:else}
|
||||||
|
Unlimited
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{#if canManage}
|
||||||
|
<td style="text-align:right">
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(s)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if ungrouped.length > 0}
|
||||||
|
<div class="text-muted" style="font-size:0.85rem">
|
||||||
|
{ungrouped.length} shift(s) with unknown departments
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
266
frontend/src/pages/Users.svelte
Normal file
266
frontend/src/pages/Users.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let users = $state([])
|
||||||
|
let loadError = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let loading = $state(true)
|
||||||
|
|
||||||
|
let showAdd = $state(false)
|
||||||
|
let adding = $state(false)
|
||||||
|
let newUsername = $state('')
|
||||||
|
let newPassword = $state('')
|
||||||
|
let newRole = $state('gate')
|
||||||
|
let newDeptIDs = $state([])
|
||||||
|
|
||||||
|
let editID = $state(null)
|
||||||
|
let editRole = $state('')
|
||||||
|
let editDeptIDs = $state([])
|
||||||
|
let editPassword = $state('')
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
|
const allDepts = liveQuery(() =>
|
||||||
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
|
||||||
|
|
||||||
|
const me = $derived(session?.user?.id)
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading = true
|
||||||
|
try {
|
||||||
|
users = await api.users.list()
|
||||||
|
} catch (err) {
|
||||||
|
loadError = err.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { loadUsers() })
|
||||||
|
|
||||||
|
async function addUser(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
adding = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const u = await api.users.create({
|
||||||
|
username: newUsername,
|
||||||
|
password: newPassword,
|
||||||
|
role: newRole,
|
||||||
|
department_ids: newDeptIDs,
|
||||||
|
})
|
||||||
|
users = [...users, u]
|
||||||
|
showAdd = false
|
||||||
|
newUsername = newPassword = ''
|
||||||
|
newRole = 'gate'
|
||||||
|
newDeptIDs = []
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(u) {
|
||||||
|
editID = u.id
|
||||||
|
editRole = u.role
|
||||||
|
editDeptIDs = [...(u.department_ids || [])]
|
||||||
|
editPassword = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser(u) {
|
||||||
|
saving = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const payload = { role: editRole, department_ids: editDeptIDs }
|
||||||
|
if (editPassword) payload.password = editPassword
|
||||||
|
const updated = await api.users.update(u.id, payload)
|
||||||
|
users = users.map(x => x.id === u.id ? updated : x)
|
||||||
|
editID = null
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(u) {
|
||||||
|
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await api.users.delete(u.id)
|
||||||
|
users = users.filter(x => x.id !== u.id)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDept(id, list) {
|
||||||
|
const idx = list.indexOf(id)
|
||||||
|
if (idx === -1) return [...list, id]
|
||||||
|
return list.filter(x => x !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deptNamesFor(ids) {
|
||||||
|
const depts = $allDepts ?? []
|
||||||
|
return ids.map(id => depts.find(d => d.id === id)?.name).filter(Boolean).join(', ') || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(r) {
|
||||||
|
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Users</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add user</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="alert alert-error">{loadError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAdd}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={addUser}>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="u-username">Username *</label>
|
||||||
|
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="u-password">Password *</label>
|
||||||
|
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="u-role">Role *</label>
|
||||||
|
<select id="u-role" bind:value={newRole}>
|
||||||
|
{#each roles as r}
|
||||||
|
<option value={r}>{roleLabel(r)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if ($allDepts ?? []).length > 0}
|
||||||
|
<div class="form-group">
|
||||||
|
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||||
|
<input type="checkbox" style="width:auto"
|
||||||
|
checked={newDeptIDs.includes(d.id)}
|
||||||
|
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
|
||||||
|
<span class="dept-dot" style="background:{d.color}"></span>
|
||||||
|
{d.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||||
|
{adding ? 'Adding…' : 'Add user'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
||||||
|
{:else if users.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No users yet</strong>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Departments</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as u (u.id)}
|
||||||
|
{#if editID === u.id}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||||
|
<td>
|
||||||
|
<select bind:value={editRole} style="width:auto;margin:0">
|
||||||
|
{#each roles as r}
|
||||||
|
<option value={r}>{roleLabel(r)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if ($allDepts ?? []).length > 0}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||||
|
<input type="checkbox" style="width:auto"
|
||||||
|
checked={editDeptIDs.includes(d.id)}
|
||||||
|
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
||||||
|
{d.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<input type="password" bind:value={editPassword}
|
||||||
|
placeholder="New password (leave blank to keep)"
|
||||||
|
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{u.username}</strong>
|
||||||
|
{#if u.id === me}
|
||||||
|
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||||
|
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||||
|
{#if u.id !== me}
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
241
frontend/src/pages/Volunteers.svelte
Normal file
241
frontend/src/pages/Volunteers.svelte
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<script>
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import CheckInButton from '../components/CheckInButton.svelte'
|
||||||
|
|
||||||
|
let { session } = $props()
|
||||||
|
|
||||||
|
let search = $state('')
|
||||||
|
let filterDept = $state('')
|
||||||
|
let filterChecked = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let showAdd = $state(false)
|
||||||
|
let adding = $state(false)
|
||||||
|
let newName = $state('')
|
||||||
|
let newEmail = $state('')
|
||||||
|
let newPhone = $state('')
|
||||||
|
let newDeptID = $state('')
|
||||||
|
let newIsLead = $state(false)
|
||||||
|
let newNote = $state('')
|
||||||
|
|
||||||
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||||
|
|
||||||
|
const allVolunteers = liveQuery(() =>
|
||||||
|
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||||
|
)
|
||||||
|
const allDepts = liveQuery(() =>
|
||||||
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const list = $allVolunteers ?? []
|
||||||
|
const s = search.toLowerCase()
|
||||||
|
return list
|
||||||
|
.filter(v => {
|
||||||
|
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||||
|
if (filterChecked === 'true' && !v.checked_in) return false
|
||||||
|
if (filterChecked === 'false' && v.checked_in) return false
|
||||||
|
if (s && !v.name.toLowerCase().includes(s) &&
|
||||||
|
!(v.email || '').toLowerCase().includes(s)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkIn(v) {
|
||||||
|
try {
|
||||||
|
const updated = await api.volunteers.checkIn(v.id)
|
||||||
|
await db.volunteers.put(updated)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVolunteer(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
adding = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name: newName,
|
||||||
|
email: newEmail,
|
||||||
|
phone: newPhone,
|
||||||
|
is_lead: newIsLead,
|
||||||
|
note: newNote,
|
||||||
|
}
|
||||||
|
if (newDeptID) data.department_id = parseInt(newDeptID)
|
||||||
|
const v = await api.volunteers.create(data)
|
||||||
|
await db.volunteers.put(v)
|
||||||
|
showAdd = false
|
||||||
|
newName = newEmail = newPhone = newNote = ''
|
||||||
|
newDeptID = ''
|
||||||
|
newIsLead = false
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVolunteer(v) {
|
||||||
|
if (!confirm(`Delete volunteer "${v.name}"?`)) return
|
||||||
|
try {
|
||||||
|
await api.volunteers.delete(v.id)
|
||||||
|
await db.volunteers.delete(v.id)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deptFor(id) {
|
||||||
|
return ($allDepts ?? []).find(d => d.id === id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Volunteers</h1>
|
||||||
|
{#if canManage}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAdd && canManage}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<form onsubmit={addVolunteer}>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="v-name">Name *</label>
|
||||||
|
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="v-email">Email</label>
|
||||||
|
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="v-phone">Phone</label>
|
||||||
|
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="v-dept">Department</label>
|
||||||
|
<select id="v-dept" bind:value={newDeptID}>
|
||||||
|
<option value="">No department</option>
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<option value={String(d.id)}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="v-note">Note</label>
|
||||||
|
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||||
|
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
||||||
|
Department lead
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||||
|
{adding ? 'Adding…' : 'Add volunteer'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input placeholder="Search name, email…" bind:value={search} />
|
||||||
|
{#if ($allDepts ?? []).length > 0}
|
||||||
|
<select bind:value={filterDept} style="width:auto">
|
||||||
|
<option value="">All departments</option>
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<option value={String(d.id)}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<select bind:value={filterChecked} style="width:auto">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="false">Not checked in</option>
|
||||||
|
<option value="true">Checked in</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||||
|
{filtered.length} shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ($allVolunteers ?? []).length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No volunteers yet</strong>
|
||||||
|
<p>Add volunteers manually.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
{#if canManage}<th></th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filtered as v (v.id)}
|
||||||
|
{@const dept = deptFor(v.department_id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{v.name}</strong>
|
||||||
|
{#if v.is_lead}
|
||||||
|
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
|
||||||
|
{/if}
|
||||||
|
{#if v.note}
|
||||||
|
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{#if dept}
|
||||||
|
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
|
{v.checked_in ? 'Checked in' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
{#if v.checked_in_at}
|
||||||
|
<div class="text-muted" style="font-size:0.75rem">
|
||||||
|
{new Date(v.checked_in_at).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if !v.checked_in}
|
||||||
|
<CheckInButton onclick={() => checkIn(v)} />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{#if canManage}
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
115
frontend/src/sync.js
Normal file
115
frontend/src/sync.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { db, getLastSync, setLastSync } from './db.js'
|
||||||
|
import { api } from './api.js'
|
||||||
|
|
||||||
|
let syncing = false
|
||||||
|
let sseSource = null
|
||||||
|
|
||||||
|
export async function syncPull() {
|
||||||
|
if (syncing) return
|
||||||
|
syncing = true
|
||||||
|
try {
|
||||||
|
const since = await getLastSync()
|
||||||
|
const data = await api.sync.pull(since)
|
||||||
|
|
||||||
|
await db.transaction('rw',
|
||||||
|
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||||
|
async () => {
|
||||||
|
if (data.event) {
|
||||||
|
await db.event.put(data.event)
|
||||||
|
}
|
||||||
|
if (data.attendees?.length) {
|
||||||
|
await db.attendees.bulkPut(data.attendees)
|
||||||
|
// Purge hard-deleted records from Dexie
|
||||||
|
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||||
|
if (deleted.length) await db.attendees.bulkDelete(deleted)
|
||||||
|
}
|
||||||
|
if (data.departments?.length) {
|
||||||
|
await db.departments.bulkPut(data.departments)
|
||||||
|
const deleted = data.departments.filter(d => d.deleted_at).map(d => d.id)
|
||||||
|
if (deleted.length) await db.departments.bulkDelete(deleted)
|
||||||
|
}
|
||||||
|
if (data.volunteers?.length) {
|
||||||
|
await db.volunteers.bulkPut(data.volunteers)
|
||||||
|
const deleted = data.volunteers.filter(v => v.deleted_at).map(v => v.id)
|
||||||
|
if (deleted.length) await db.volunteers.bulkDelete(deleted)
|
||||||
|
}
|
||||||
|
if (data.shifts?.length) {
|
||||||
|
await db.shifts.bulkPut(data.shifts)
|
||||||
|
const deleted = data.shifts.filter(s => s.deleted_at).map(s => s.id)
|
||||||
|
if (deleted.length) await db.shifts.bulkDelete(deleted)
|
||||||
|
}
|
||||||
|
if (data.volunteer_shifts?.length) {
|
||||||
|
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await setLastSync(data.server_time)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Sync pull failed:', err.message)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSSE(onEvent) {
|
||||||
|
if (sseSource) return
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
// Get token synchronously from Dexie — SSE doesn't support headers natively,
|
||||||
|
// so we pass the token as a query param (acceptable since it's same-origin HTTPS).
|
||||||
|
db.session.get(1).then(session => {
|
||||||
|
if (!session?.token) return
|
||||||
|
|
||||||
|
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
|
||||||
|
|
||||||
|
sseSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.data)
|
||||||
|
if (payload.event === 'checkin') {
|
||||||
|
// Apply check-in to local Dexie immediately
|
||||||
|
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||||
|
db.attendees.put(payload.data.attendee)
|
||||||
|
}
|
||||||
|
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
||||||
|
db.volunteers.put(payload.data.volunteer)
|
||||||
|
}
|
||||||
|
onEvent?.(payload)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
sseSource.onerror = () => {
|
||||||
|
sseSource?.close()
|
||||||
|
sseSource = null
|
||||||
|
// Reconnect after 5s
|
||||||
|
setTimeout(connect, 5000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSSE() {
|
||||||
|
sseSource?.close()
|
||||||
|
sseSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for sync when online, with exponential backoff on failure
|
||||||
|
let syncInterval = null
|
||||||
|
|
||||||
|
export function startSyncLoop(intervalMs = 30000) {
|
||||||
|
if (syncInterval) return
|
||||||
|
syncInterval = setInterval(() => {
|
||||||
|
if (navigator.onLine) syncPull()
|
||||||
|
}, intervalMs)
|
||||||
|
window.addEventListener('online', () => syncPull())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSyncLoop() {
|
||||||
|
clearInterval(syncInterval)
|
||||||
|
syncInterval = null
|
||||||
|
}
|
||||||
8
frontend/svelte.config.js
Normal file
8
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8180',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
22
go.mod
Normal file
22
go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
module turnpike
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
57
go.sum
Normal file
57
go.sum
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
167
handle_attendees.go
Normal file
167
handle_attendees.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
types, _ := app.attendeeTicketTypes()
|
||||||
|
total, checkedIn, _ := app.attendeeCounts()
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"attendees": attendees,
|
||||||
|
"ticket_types": types,
|
||||||
|
"total": total,
|
||||||
|
"checked_in": checkedIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var a Attendee
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := app.createAttendee(a)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := app.getAttendee(id)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var a Attendee
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.ID = id
|
||||||
|
if err := app.updateAttendee(a); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := app.getAttendee(id)
|
||||||
|
writeJSON(w, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.deleteAttendee(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCheckInAttendee handles POST /api/attendees/:id/checkin.
|
||||||
|
// Optional body: {"count": N, "also_volunteer": true}
|
||||||
|
// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true
|
||||||
|
// and the attendee has a linked volunteer record.
|
||||||
|
func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
AlsoVolunteer bool `json:"also_volunteer"`
|
||||||
|
}
|
||||||
|
body.Count = 1
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body.Count < 1 {
|
||||||
|
body.Count = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
a, err := app.checkInAttendee(id, claims.UserID, body.Count)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{"attendee": a}
|
||||||
|
|
||||||
|
if body.AlsoVolunteer {
|
||||||
|
v, _ := app.getVolunteerByAttendeeID(id)
|
||||||
|
if v != nil {
|
||||||
|
if !v.CheckedIn {
|
||||||
|
if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil {
|
||||||
|
result["volunteer"] = v2
|
||||||
|
app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["volunteer"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a})
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attendees, err := app.listAttendees("", "", "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`)
|
||||||
|
wr := csv.NewWriter(w)
|
||||||
|
wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"})
|
||||||
|
for _, a := range attendees {
|
||||||
|
ci := "no"
|
||||||
|
if a.CheckedIn {
|
||||||
|
ci = "yes"
|
||||||
|
}
|
||||||
|
wr.Write([]string{
|
||||||
|
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType,
|
||||||
|
strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount),
|
||||||
|
a.Note, ci,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wr.Flush()
|
||||||
|
}
|
||||||
49
handle_auth.go
Normal file
49
handle_auth.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, hash, err := app.getUserByUsername(body.Username)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil || !checkPassword(hash, body.Password) {
|
||||||
|
writeError(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.signToken(user)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "token error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"token": token, "user": user})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]string{"ok": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
user, err := app.getUserByID(claims.UserID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, user)
|
||||||
|
}
|
||||||
75
handle_departments.go
Normal file
75
handle_departments.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleListDepartments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
depts, err := app.listDepartments("")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, depts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var d Department
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.Color == "" {
|
||||||
|
d.Color = "#6366f1"
|
||||||
|
}
|
||||||
|
created, err := app.createDepartment(d)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateDepartment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var d Department
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.ID = id
|
||||||
|
if err := app.updateDepartment(d); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := app.getDepartment(id)
|
||||||
|
writeJSON(w, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDeleteDepartment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.deleteDepartment(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
37
handle_event.go
Normal file
37
handle_event.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleGetEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
event, err := app.getEvent()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var e Event
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.Name == "" || e.StartDate == "" || e.EndDate == "" {
|
||||||
|
writeError(w, "name, start_date, and end_date are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.upsertEvent(e); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event, _ := app.getEvent()
|
||||||
|
writeJSON(w, event)
|
||||||
|
}
|
||||||
158
handle_import.go
Normal file
158
handle_import.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportResult struct {
|
||||||
|
Inserted int `json:"inserted"`
|
||||||
|
Grouped int `json:"grouped"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
writeError(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("csv")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "csv file required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
result, err := app.importCSV(file)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Errors == nil {
|
||||||
|
result.Errors = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
||||||
|
reader := csv.NewReader(r)
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
reader.LazyQuotes = true
|
||||||
|
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return ImportResult{}, fmt.Errorf("reading header: %w", err)
|
||||||
|
}
|
||||||
|
if len(header) > 0 {
|
||||||
|
header[0] = strings.TrimPrefix(header[0], "\xef\xbb\xbf") // strip BOM
|
||||||
|
}
|
||||||
|
|
||||||
|
colIndex := make(map[string]int)
|
||||||
|
for i, h := range header {
|
||||||
|
colIndex[strings.ToLower(strings.TrimSpace(h))] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int
|
||||||
|
hasEmail, hasTicketID, hasTicketType, hasNote bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if idx, ok := colIndex["patron name"]; ok {
|
||||||
|
// CrowdWork / ticketing platform format
|
||||||
|
nameIdx = idx
|
||||||
|
if idx, ok := colIndex["patron email"]; ok {
|
||||||
|
emailIdx, hasEmail = idx, true
|
||||||
|
}
|
||||||
|
if idx, ok := colIndex["tier name"]; ok {
|
||||||
|
ticketTypeIdx, hasTicketType = idx, true
|
||||||
|
}
|
||||||
|
if idx, ok := colIndex["order number"]; ok {
|
||||||
|
ticketIDIdx, hasTicketID = idx, true
|
||||||
|
}
|
||||||
|
} else if idx, ok := colIndex["name"]; ok {
|
||||||
|
// Generic format
|
||||||
|
nameIdx = idx
|
||||||
|
if idx, ok := colIndex["email"]; ok {
|
||||||
|
emailIdx, hasEmail = idx, true
|
||||||
|
}
|
||||||
|
if idx, ok := colIndex["ticket_id"]; ok {
|
||||||
|
ticketIDIdx, hasTicketID = idx, true
|
||||||
|
}
|
||||||
|
if idx, ok := colIndex["ticket_type"]; ok {
|
||||||
|
ticketTypeIdx, hasTicketType = idx, true
|
||||||
|
}
|
||||||
|
if idx, ok := colIndex["note"]; ok {
|
||||||
|
noteIdx, hasNote = idx, true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ImportResult
|
||||||
|
lineNum := 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
record, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineNum++
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("line %d: %v", lineNum, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(csvGet(record, nameIdx))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
a := Attendee{Name: name}
|
||||||
|
if hasEmail {
|
||||||
|
a.Email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||||
|
}
|
||||||
|
if hasTicketID {
|
||||||
|
a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||||
|
}
|
||||||
|
if hasTicketType {
|
||||||
|
a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||||
|
}
|
||||||
|
if hasNote {
|
||||||
|
a.Note = strings.TrimSpace(csvGet(record, noteIdx))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = app.createAttendee(a)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
// CrowdWork exports one row per ticket under the purchaser's name.
|
||||||
|
// If we have a ticket_id and the same (name, ticket_id) already exists,
|
||||||
|
// increment party_size instead of skipping.
|
||||||
|
if hasTicketID && a.TicketID != "" {
|
||||||
|
merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID)
|
||||||
|
if mergeErr == nil && merged {
|
||||||
|
result.Grouped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Skipped++
|
||||||
|
} else {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Inserted++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func csvGet(record []string, idx int) string {
|
||||||
|
if idx < len(record) {
|
||||||
|
return record[idx]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
132
handle_kiosk.go
Normal file
132
handle_kiosk.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
||||||
|
// available open shifts in their department. Authenticated by volunteer token only —
|
||||||
|
// no JWT required.
|
||||||
|
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
a, err := app.getAttendeeByToken(token)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||||
|
if v == nil {
|
||||||
|
writeError(w, "no volunteer record linked to this token", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
||||||
|
if assigned == nil {
|
||||||
|
assigned = []Shift{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var available []Shift
|
||||||
|
if v.DepartmentID != nil {
|
||||||
|
available, _ = app.listOpenShiftsForDept(*v.DepartmentID)
|
||||||
|
}
|
||||||
|
if available == nil {
|
||||||
|
available = []Shift{}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"volunteer": v,
|
||||||
|
"shifts": assigned,
|
||||||
|
"available": available,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKioskClaim assigns the volunteer to a shift.
|
||||||
|
// Without ?force=true it returns 409 with conflicting shifts on overlap.
|
||||||
|
func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
shiftID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := app.getAttendeeByToken(token)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||||
|
if v == nil {
|
||||||
|
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
force := r.URL.Query().Get("force") == "true"
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
conflicts, err := app.checkShiftConflict(v.ID, shiftID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(conflicts) > 0 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"conflict": true,
|
||||||
|
"conflicting_shifts": conflicts,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shift, err := app.getShift(shiftID)
|
||||||
|
if err != nil || shift == nil {
|
||||||
|
writeError(w, "shift not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shift.Capacity > 0 {
|
||||||
|
count, _ := app.shiftAssignedCount(shiftID)
|
||||||
|
if count >= shift.Capacity {
|
||||||
|
writeError(w, "shift is full", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.assignShift(v.ID, shiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.handleKioskGet(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKioskUnclaim removes the volunteer from a shift.
|
||||||
|
func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
shiftID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := app.getAttendeeByToken(token)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||||
|
if v == nil {
|
||||||
|
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.handleKioskGet(w, r)
|
||||||
|
}
|
||||||
78
handle_settings.go
Normal file
78
handle_settings.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := app.loadSMTPConfig()
|
||||||
|
|
||||||
|
baseURL := app.baseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
pass := ""
|
||||||
|
if cfg.Password != "" {
|
||||||
|
pass = "***"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"smtp_host": cfg.Host,
|
||||||
|
"smtp_port": cfg.Port,
|
||||||
|
"smtp_user": cfg.User,
|
||||||
|
"smtp_password": pass,
|
||||||
|
"smtp_from": cfg.From,
|
||||||
|
"smtp_from_name": cfg.FromName,
|
||||||
|
"base_url": baseURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"}
|
||||||
|
for _, k := range keys {
|
||||||
|
v, ok := body[k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var val string
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case string:
|
||||||
|
if k == "smtp_password" && vv == "" {
|
||||||
|
continue // don't erase the stored password with an empty value
|
||||||
|
}
|
||||||
|
val = vv
|
||||||
|
case float64:
|
||||||
|
val = strconv.Itoa(int(vv))
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, k, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.handleGetSettings(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.To == "" {
|
||||||
|
writeError(w, "to email address required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := app.loadSMTPConfig()
|
||||||
|
if err := sendEmail(cfg, body.To, "Turnpike test email", "This is a test email from your Turnpike instance. SMTP is configured correctly."); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
179
handle_shifts.go
Normal file
179
handle_shifts.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
var deptID *int
|
||||||
|
if d := q.Get("dept"); d != "" {
|
||||||
|
id, err := strconv.Atoi(d)
|
||||||
|
if err == nil {
|
||||||
|
deptID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||||
|
deptID = &claims.DeptIDs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, shifts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var s Shift
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.DepartmentID == 0 || s.Name == "" || s.Day == "" || s.StartTime == "" || s.EndTime == "" {
|
||||||
|
writeError(w, "department_id, name, day, start_time, end_time required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := app.createShift(s)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s Shift
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" {
|
||||||
|
existing, _ := app.getShift(id)
|
||||||
|
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.ID = id
|
||||||
|
if err := app.updateShift(s); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := app.getShift(id)
|
||||||
|
writeJSON(w, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.deleteShift(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAssignShiftVolunteer is the shift-centric assignment endpoint.
|
||||||
|
// POST /api/shifts/:id/volunteers body: {"volunteer_id": N, "force": false}
|
||||||
|
// Checks for scheduling conflicts unless force=true.
|
||||||
|
func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
shiftID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
VolunteerID int `json:"volunteer_id"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.VolunteerID == 0 {
|
||||||
|
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !body.Force {
|
||||||
|
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(conflicts) > 0 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"conflict": true,
|
||||||
|
"conflicting_shifts": conflicts,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.assignShift(body.VolunteerID, shiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnassignShiftVolunteer removes a volunteer from a shift.
|
||||||
|
// DELETE /api/shifts/:id/volunteers/:volunteer_id
|
||||||
|
func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
shiftID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
volunteerID, err := strconv.Atoi(r.PathValue("volunteer_id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderShifts bulk-updates shift positions.
|
||||||
|
// POST /api/shifts/reorder body: [{"id": N, "position": N}, ...]
|
||||||
|
func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var raw []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil || len(raw) == 0 {
|
||||||
|
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
positions := make([]struct{ ID, Position int }, len(raw))
|
||||||
|
for i, p := range raw {
|
||||||
|
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||||
|
}
|
||||||
|
if err := app.reorderShifts(positions); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
86
handle_sync.go
Normal file
86
handle_sync.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSyncPull returns all records modified since the `since` query parameter.
|
||||||
|
// If `since` is empty, returns all records (initial sync).
|
||||||
|
func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||||
|
since := r.URL.Query().Get("since")
|
||||||
|
|
||||||
|
event, _ := app.getEvent()
|
||||||
|
attendees, _ := app.attendeesSince(since)
|
||||||
|
departments, _ := app.listDepartments(since)
|
||||||
|
volunteers, _ := app.listVolunteers("", nil, since)
|
||||||
|
shifts, _ := app.listShifts(nil, "", since)
|
||||||
|
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||||
|
|
||||||
|
if attendees == nil {
|
||||||
|
attendees = []Attendee{}
|
||||||
|
}
|
||||||
|
if departments == nil {
|
||||||
|
departments = []Department{}
|
||||||
|
}
|
||||||
|
if volunteers == nil {
|
||||||
|
volunteers = []Volunteer{}
|
||||||
|
}
|
||||||
|
if shifts == nil {
|
||||||
|
shifts = []Shift{}
|
||||||
|
}
|
||||||
|
if volunteerShifts == nil {
|
||||||
|
volunteerShifts = []VolunteerShift{}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
"event": event,
|
||||||
|
"attendees": attendees,
|
||||||
|
"departments": departments,
|
||||||
|
"volunteers": volunteers,
|
||||||
|
"shifts": shifts,
|
||||||
|
"volunteer_shifts": volunteerShifts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSyncStream is an SSE endpoint that broadcasts real-time events
|
||||||
|
// (check-ins, etc.) to connected clients.
|
||||||
|
func (app *App) handleSyncStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := app.broker.subscribe()
|
||||||
|
defer app.broker.unsubscribe(ch)
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "data: {\"event\":\"connected\"}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case payload, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-ticker.C:
|
||||||
|
fmt.Fprintf(w, ": ping\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
handle_tokens.go
Normal file
98
handle_tokens.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGenerateTokens creates volunteer_token values for all attendees that don't have one.
|
||||||
|
func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
count, err := app.generateTokensForAll()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"generated": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportTokenLinks streams a CSV download with token signup links,
|
||||||
|
// compatible with MailChimp / Zeffy bulk-send workflows.
|
||||||
|
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attendees, err := app.listAttendees("", "", "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := app.baseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
|
||||||
|
}
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/csv")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`)
|
||||||
|
wr := csv.NewWriter(w)
|
||||||
|
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
||||||
|
for _, a := range attendees {
|
||||||
|
if a.VolunteerToken == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
firstName := a.Name
|
||||||
|
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||||
|
firstName = parts[0]
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||||
|
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||||
|
}
|
||||||
|
wr.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEmailToken sends a token email to a single attendee.
|
||||||
|
func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := app.getAttendee(id)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.sendTokenEmail(*a); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email.
|
||||||
|
func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attendees, err := app.listAttendees("", "", "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sent, skipped int
|
||||||
|
var errors []string
|
||||||
|
for _, a := range attendees {
|
||||||
|
if a.Email == "" || a.VolunteerToken == nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := app.sendTokenEmail(a); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err))
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors == nil {
|
||||||
|
errors = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"sent": sent, "skipped": skipped, "errors": errors})
|
||||||
|
}
|
||||||
105
handle_users.go
Normal file
105
handle_users.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := app.listUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Username == "" || body.Password == "" || body.Role == "" {
|
||||||
|
writeError(w, "username, password, and role are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := hashPassword(body.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "hash error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DepartmentIDs == nil {
|
||||||
|
body.DepartmentIDs = []int{}
|
||||||
|
}
|
||||||
|
user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DepartmentIDs == nil {
|
||||||
|
body.DepartmentIDs = []int{}
|
||||||
|
}
|
||||||
|
if body.Role != "" {
|
||||||
|
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Password != "" {
|
||||||
|
hash, err := hashPassword(body.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "hash error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.updateUserPassword(id, hash); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, _ := app.getUserByID(id)
|
||||||
|
writeJSON(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.UserID == id {
|
||||||
|
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.deleteUser(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
181
handle_volunteers.go
Normal file
181
handle_volunteers.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
search := q.Get("search")
|
||||||
|
since := q.Get("since")
|
||||||
|
|
||||||
|
var deptID *int
|
||||||
|
if d := q.Get("dept"); d != "" {
|
||||||
|
id, err := strconv.Atoi(d)
|
||||||
|
if err == nil {
|
||||||
|
deptID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||||
|
deptID = &claims.DeptIDs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
volunteers, err := app.listVolunteers(search, deptID, since)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, volunteers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var v Volunteer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" {
|
||||||
|
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created, err := app.createVolunteer(v)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleGetVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := app.getVolunteer(id)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v Volunteer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
if claims.Role == "volunteer_lead" {
|
||||||
|
existing, _ := app.getVolunteer(id)
|
||||||
|
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||||
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.ID = id
|
||||||
|
if err := app.updateVolunteer(v); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := app.getVolunteer(id)
|
||||||
|
writeJSON(w, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.deleteVolunteer(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := claimsFromContext(r)
|
||||||
|
v, err := app.checkInVolunteer(id, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v})
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ShiftID int `json:"shift_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ShiftID == 0 {
|
||||||
|
writeError(w, "shift_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.assignShift(volunteerID, body.ShiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shiftID, err := strconv.Atoi(r.PathValue("shift_id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inSlice(v int, s []int) bool {
|
||||||
|
for _, x := range s {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
214
main.go
Normal file
214
main.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed frontend/dist
|
||||||
|
var frontendFS embed.FS
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
db *sql.DB
|
||||||
|
secret string
|
||||||
|
tokenExpiry int
|
||||||
|
broker *Broker
|
||||||
|
|
||||||
|
// SMTP settings — populated from CLI flags; config table fills any gaps.
|
||||||
|
smtpHost string
|
||||||
|
smtpPort int
|
||||||
|
smtpUser string
|
||||||
|
smtpPassword string
|
||||||
|
smtpFrom string
|
||||||
|
smtpFromName string
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", "0.0.0.0:8180", "listen address")
|
||||||
|
dbPath := flag.String("db", "turnpike.db", "SQLite database path")
|
||||||
|
secret := flag.String("secret", "", "JWT signing secret (or set TURNPIKE_SECRET)")
|
||||||
|
tokenExpiry := flag.Int("token-expiry", 24, "JWT expiry in hours")
|
||||||
|
smtpHost := flag.String("smtp-host", "", "SMTP server hostname")
|
||||||
|
smtpPort := flag.Int("smtp-port", 0, "SMTP server port (0 = use stored value or 587)")
|
||||||
|
smtpUser := flag.String("smtp-user", "", "SMTP username")
|
||||||
|
smtpPass := flag.String("smtp-password", "", "SMTP password")
|
||||||
|
smtpFrom := flag.String("smtp-from", "", "SMTP from address")
|
||||||
|
smtpName := flag.String("smtp-from-name", "", "SMTP from display name")
|
||||||
|
baseURL := flag.String("base-url", "", "Public base URL for volunteer token links (e.g. https://example.com)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *secret == "" {
|
||||||
|
*secret = os.Getenv("TURNPIKE_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := initDB(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database init: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
db: db,
|
||||||
|
tokenExpiry: *tokenExpiry,
|
||||||
|
broker: newBroker(),
|
||||||
|
smtpHost: *smtpHost,
|
||||||
|
smtpPort: *smtpPort,
|
||||||
|
smtpUser: *smtpUser,
|
||||||
|
smtpPassword: *smtpPass,
|
||||||
|
smtpFrom: *smtpFrom,
|
||||||
|
smtpFromName: *smtpName,
|
||||||
|
baseURL: *baseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if *secret == "" {
|
||||||
|
*secret, err = app.getOrCreateSecret()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("secret: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.secret = *secret
|
||||||
|
|
||||||
|
if err := app.bootstrapAdmin(); err != nil {
|
||||||
|
log.Fatalf("bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
app.registerRoutes(mux)
|
||||||
|
|
||||||
|
log.Printf("Turnpike listening on %s", *addr)
|
||||||
|
log.Fatal(http.ListenAndServe(*addr, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
|
auth := app.requireAuth
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/login", app.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/logout", auth(app.handleLogout))
|
||||||
|
mux.HandleFunc("GET /api/me", auth(app.handleMe))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||||
|
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
|
||||||
|
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate"))
|
||||||
|
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||||
|
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
|
||||||
|
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||||
|
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
|
||||||
|
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
|
||||||
|
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||||
|
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||||
|
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin"))
|
||||||
|
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||||
|
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
|
||||||
|
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull))
|
||||||
|
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
||||||
|
|
||||||
|
// Kiosk — authenticated by volunteer token, no JWT required.
|
||||||
|
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
||||||
|
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
||||||
|
mux.HandleFunc("DELETE /api/v/{token}/shifts/{id}", app.handleKioskUnclaim)
|
||||||
|
|
||||||
|
// Serve embedded frontend, falling through to index.html for SPA routing.
|
||||||
|
distFS, err := fs.Sub(frontendFS, "frontend/dist")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("embed: %v", err)
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path != "/" {
|
||||||
|
// Strip leading slash and check if file exists
|
||||||
|
if _, err := fs.Stat(distFS, path[1:]); err == nil {
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All other paths: serve index.html (SPA client-side routing)
|
||||||
|
r2 := *r
|
||||||
|
r2.URL.Path = "/"
|
||||||
|
fileServer.ServeHTTP(w, &r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) bootstrapAdmin() error {
|
||||||
|
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
|
||||||
|
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
||||||
|
if adminUser == "" || adminPass == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := app.countUsers()
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := hashPassword(adminPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = app.createUser(adminUser, hash, "admin", []int{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Created admin user: %s", adminUser)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) getOrCreateSecret() (string, error) {
|
||||||
|
app.db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||||
|
var s string
|
||||||
|
err := app.db.QueryRow(`SELECT value FROM config WHERE key = 'jwt_secret'`).Scan(&s)
|
||||||
|
if err == nil && s != "" {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
secret := generateSecret()
|
||||||
|
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('jwt_secret', ?)`, secret)
|
||||||
|
log.Printf("Generated new JWT secret and stored in database")
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSecret() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue