Compare commits
8 Commits
64f4563bfa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
67335f0b45
|
|||
|
87caf478df
|
|||
|
55f68c571e
|
|||
|
dc7be0c53a
|
|||
|
25fd4a8be7
|
|||
|
9ecf919d68
|
|||
|
28310e5aa1
|
|||
|
4989ff1061
|
8
.claude/agents/auto-review-commit.md
Normal file
8
.claude/agents/auto-review-commit.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: Auto-trigger review and commit when feature implementation is complete
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Auto-Trigger Review & Commit
|
||||||
|
|
||||||
|
When a feature implementation is complete, automatically execute the `/review-and-commit` workflow without waiting for the user to manually request it. Do not automatically push and watch.
|
||||||
40
.claude/agents/go-conventions.md
Normal file
40
.claude/agents/go-conventions.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Go conventions and patterns for this project
|
||||||
|
globs: "**/*.go"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Go Conventions
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Every exported function that does I/O takes `context.Context` as its first argument.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
- Define package-level sentinel errors for expected conditions:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ErrNotFound = fmt.Errorf("not found")
|
||||||
|
```
|
||||||
|
|
||||||
|
- Wrap unexpected errors with `fmt.Errorf("doing X: %w", err)` to preserve the chain.
|
||||||
|
- Callers check expected errors with `errors.Is(err, store.ErrNotFound)`.
|
||||||
|
|
||||||
|
## UUIDs
|
||||||
|
|
||||||
|
- Use `github.com/google/uuid` for all UUID types.
|
||||||
|
- Model structs use `uuid.UUID`, not `string`, for ID fields.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- Use `?` parameter placeholders (MySQL style), never string interpolation.
|
||||||
|
- Use `gen_random_uuid()` for server-generated UUIDs (Postgres built-in).
|
||||||
|
- **Queries**: use [sqlc](https://sqlc.dev/) to generate type-safe Go from SQL.
|
||||||
|
Write annotated SQL in `internal/store/queries/*.sql`; run `task sqlc` to
|
||||||
|
regenerate. Never hand-write query code in Go — edit the `.sql` source instead.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Use the standard `testing` package. No external assertion libraries.
|
||||||
|
- Test functions follow `TestTypeName_MethodName` naming
|
||||||
66
.claude/agents/go-dev.md
Normal file
66
.claude/agents/go-dev.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
description: Standard Go development commands for this project
|
||||||
|
globs: "**/*.go"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Go Development Commands
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests in a specific package
|
||||||
|
go test ./internal/checkin/...
|
||||||
|
|
||||||
|
# Run a single test by name
|
||||||
|
go test ./internal/checkin/... -run TestCheckOut
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run tests with race detector
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formatting and Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format all Go files
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
# Organize imports (group stdlib, external, internal)
|
||||||
|
goimports -w .
|
||||||
|
|
||||||
|
# Vet for common mistakes
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Generation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate sqlc query code after editing internal/store/queries/*.sql
|
||||||
|
task sqlc
|
||||||
|
|
||||||
|
# Regenerate proto/gRPC code after editing .proto files
|
||||||
|
task generate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile all packages (catches errors without producing binaries)
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# Tidy module dependencies after adding/removing imports
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
## After any code change
|
||||||
|
|
||||||
|
1. `gofmt -w .`
|
||||||
|
2. `goimports -w .` (if imports changed)
|
||||||
|
3. `go vet ./...`
|
||||||
|
4. `go test ./...`
|
||||||
12
.claude/agents/minimal-changes.md
Normal file
12
.claude/agents/minimal-changes.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Make only the changes the user asked for
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Minimal Changes
|
||||||
|
|
||||||
|
When implementing a requested change, make **only** the changes the user asked for.
|
||||||
|
|
||||||
|
- Do not make additional "cleanup" or "consistency" changes alongside the requested work (e.g. removing annotations you consider redundant, restructuring related code, adding backward-compatibility guards).
|
||||||
|
- If you notice something that *should* be changed but wasn't requested, mention it after completing the requested work — don't silently include it.
|
||||||
|
- When presenting multiple implementation paths, wait for the user to choose before writing any code.
|
||||||
13
.claude/agents/taskfile-not-make.md
Normal file
13
.claude/agents/taskfile-not-make.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description: Use go-task (Taskfile) instead of Make for task automation
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use Taskfile, not Make
|
||||||
|
|
||||||
|
This project uses [go-task](https://taskfile.dev/) (`Taskfile.yaml`) for task automation. **Do not** use Makefiles.
|
||||||
|
|
||||||
|
- Task definitions go in `Taskfile.yaml` at the repo root.
|
||||||
|
- Use `task <name>` to run tasks (e.g. `task test`, `task db`).
|
||||||
|
- Use colon-separated namespacing for related tasks (e.g. `task db:stop`).
|
||||||
|
- When adding new automation, add a task to `Taskfile.yaml` rather than creating a Makefile or shell script.
|
||||||
14
.claude/agents/test-isolation.md
Normal file
14
.claude/agents/test-isolation.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
description: Tests must be self-contained; mock external service dependencies
|
||||||
|
globs: "**/*_test.go"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Isolation
|
||||||
|
|
||||||
|
Tests must be self-contained. The only shared infrastructure allowed is the database.
|
||||||
|
|
||||||
|
- **Database**: tests may depend on a real database connection — this is expected and acceptable.
|
||||||
|
- **External services** (gRPC clients, HTTP APIs, third-party SDKs, etc.): must be mocked or stubbed. Tests must never make real calls to external services.
|
||||||
|
- Use interfaces for service dependencies so they are straightforward to mock in tests.
|
||||||
|
- Keep mocks minimal — only stub the methods the test actually exercises.
|
||||||
11
.claude/agents/testing-mocks.md
Normal file
11
.claude/agents/testing-mocks.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: Preferences for testing and mocks
|
||||||
|
globs: ["**/*_test.go", "e2e/**/*.go"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Mocks
|
||||||
|
|
||||||
|
When writing or updating tests:
|
||||||
|
- Always prefer real code, a local version of a server (like `httptest`), etc., to static mocks.
|
||||||
|
- Prefer dependency injection if useful for testing.
|
||||||
|
- A mock implementation should be the absolute last resort.
|
||||||
12
.claude/settings.json
Normal file
12
.claude/settings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go test ./...)",
|
||||||
|
"Bash(gofmt -w .)",
|
||||||
|
"Bash(go build ./...)",
|
||||||
|
"Bash(go vet ./...)",
|
||||||
|
"Bash(go test -count=1 -v -coverprofile=coverage.out ./...)",
|
||||||
|
"Bash(go tool cover -func=coverage.out)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
93
.claude/skills/review-and-commit/SKILL.md
Normal file
93
.claude/skills/review-and-commit/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: review-and-commit
|
||||||
|
description: Review and Commit
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review and Commit
|
||||||
|
|
||||||
|
Perform the following steps in order. Stop and report if any step fails.
|
||||||
|
|
||||||
|
## 1. Run Code Checks
|
||||||
|
|
||||||
|
Run these in parallel where possible:
|
||||||
|
|
||||||
|
- `task lint` (buf format + buf lint + gofmt + goimports + go vet)
|
||||||
|
- `task generate` then verify no diff in generated files (`git diff --exit-code gen/ api/openapi.yaml`)
|
||||||
|
|
||||||
|
Fix any issues found. Re-run checks after fixes.
|
||||||
|
|
||||||
|
## 2. Run Tests with Coverage
|
||||||
|
|
||||||
|
Run tests uncached with verbose output and coverage:
|
||||||
|
|
||||||
|
```
|
||||||
|
go test -count=1 -v -coverprofile=coverage.out ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then analyze coverage:
|
||||||
|
|
||||||
|
```
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage analysis
|
||||||
|
|
||||||
|
1. **Baseline**: before the review, capture coverage on the base branch (or use the most recent known coverage if available). If no baseline exists, use the current run as the initial baseline.
|
||||||
|
2. **Compare**: check total coverage and per-package coverage for packages that contain changed files.
|
||||||
|
3. **Thresholds**:
|
||||||
|
- If total coverage drops by **2 or more percentage points**, suggest adding tests and ask for confirmation before committing. Do not block the commit.
|
||||||
|
- If a changed package has **0% coverage**, mention it in the report unless the package is purely generated code or contains only types/constants.
|
||||||
|
4. **Report**: include a short coverage summary in the findings (total %, per-changed-package %, and delta if baseline is available).
|
||||||
|
|
||||||
|
Clean up `coverage.out` after analysis.
|
||||||
|
|
||||||
|
## 3. Review Changed Go Files
|
||||||
|
|
||||||
|
For each `.go` file in the diff (excluding `gen/`), review against:
|
||||||
|
|
||||||
|
### Go Code Review Comments (go.dev/wiki/CodeReviewComments)
|
||||||
|
|
||||||
|
- Comment sentences: doc comments are full sentences starting with the name, ending with a period.
|
||||||
|
- Contexts: `context.Context` as first param, never stored in structs.
|
||||||
|
- Error strings: lowercase, no trailing punctuation.
|
||||||
|
- Handle errors: no discarded errors with `_`.
|
||||||
|
- Imports: grouped (stdlib, external, internal), no unnecessary renames.
|
||||||
|
- Indent error flow: normal path at minimal indentation, error handling first.
|
||||||
|
- Initialisms: `ID`, `URL`, `HTTP` — consistent casing.
|
||||||
|
- Interfaces: defined where used, not where implemented.
|
||||||
|
- Named result parameters: only when they clarify meaning.
|
||||||
|
- Receiver names: short, consistent, never `this`/`self`.
|
||||||
|
- Variable names: short for narrow scope, descriptive for wide scope.
|
||||||
|
|
||||||
|
### Effective Go (go.dev/doc/effective_go)
|
||||||
|
|
||||||
|
- Zero value useful: structs should be usable without explicit init.
|
||||||
|
- Don't panic: use error returns, not panic, for normal error handling.
|
||||||
|
- Goroutine lifetimes: clear when/whether goroutines exit.
|
||||||
|
- Embedding: prefer embedding over forwarding methods when appropriate.
|
||||||
|
- Concurrency: share memory by communicating, not vice versa.
|
||||||
|
|
||||||
|
### Go Proverbs (go-proverbs.github.io)
|
||||||
|
|
||||||
|
- The bigger the interface, the weaker the abstraction.
|
||||||
|
- A little copying is better than a little dependency.
|
||||||
|
- Clear is better than clever.
|
||||||
|
- Errors are values — handle them, don't just check them.
|
||||||
|
- Don't panic.
|
||||||
|
- Make the zero value useful.
|
||||||
|
- Design the architecture, name the components, document the details.
|
||||||
|
|
||||||
|
## 4. Report Findings
|
||||||
|
|
||||||
|
Present findings grouped by severity:
|
||||||
|
|
||||||
|
- **Must fix**: violations that would break conventions, correctness, or backwards compatibility.
|
||||||
|
- **Should fix**: style, clarity, or maintainability issues.
|
||||||
|
- **Nit**: minor suggestions, optional.
|
||||||
|
|
||||||
|
Apply must-fix and should-fix changes (ask if unsure about any). Re-run checks.
|
||||||
|
|
||||||
|
## 5. Commit
|
||||||
|
|
||||||
|
After all checks pass and review issues are resolved, create a commit following the repository's commit conventions. Do not push unless asked.
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
PORT=8080
|
||||||
|
DATABASE_DSN=walkies.db
|
||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
STATIC_DIR=./web/build
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,27 +1,26 @@
|
|||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
||||||
#
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
# vendor/
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Go binary
|
||||||
|
/walkies
|
||||||
|
|
||||||
|
# Node
|
||||||
|
web/node_modules/
|
||||||
|
web/build/
|
||||||
|
|||||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
A `Taskfile.yml` is provided — run `task` to list all tasks. Key ones:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task build # build frontend + Go binary
|
||||||
|
task go:run # build frontend then start server on :8080
|
||||||
|
task web:dev # frontend hot-reload dev server on :3000
|
||||||
|
task go:test # run all Go tests
|
||||||
|
task go:test:verbose
|
||||||
|
task go:lint # go vet
|
||||||
|
task web:test # frontend tests
|
||||||
|
task docker:up # docker-compose up --build
|
||||||
|
task clean # remove build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
For a single Go package test: `go test ./internal/volunteer/...`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Go Backend
|
||||||
|
|
||||||
|
Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package:
|
||||||
|
|
||||||
|
| Package | Responsibility |
|
||||||
|
|---------|---------------|
|
||||||
|
| `internal/volunteer` | Volunteer CRUD; also owns `/auth/login` and `/auth/register` handlers |
|
||||||
|
| `internal/schedule` | Shift scheduling |
|
||||||
|
| `internal/timeoff` | Time-off requests and admin review |
|
||||||
|
| `internal/checkin` | Check-in / check-out and history |
|
||||||
|
| `internal/notification` | Per-volunteer notifications |
|
||||||
|
| `internal/auth` | JWT issuance (`auth.Service`) and `HashPassword` |
|
||||||
|
| `internal/db` | MySQL open + one-shot schema migration (`db.Migrate`) |
|
||||||
|
| `internal/respond` | `respond.JSON` / `respond.Error` helpers |
|
||||||
|
| `internal/server` | Wires all handlers into a chi router; serves static files at `/` |
|
||||||
|
| `internal/server/middleware` | `Authenticate` (JWT) and `RequireAdmin` middleware; `ClaimsFromContext` |
|
||||||
|
|
||||||
|
`cmd/server/main.go` opens the DB, runs migrations, calls `server.New`, and starts `http.ListenAndServe`.
|
||||||
|
|
||||||
|
All API routes are prefixed `/api/v1`. The Go binary serves the compiled React app from `STATIC_DIR` (default `./web/dist`) for all non-API routes.
|
||||||
|
|
||||||
|
### React Frontend
|
||||||
|
|
||||||
|
Standard CRA layout in `web/src/`:
|
||||||
|
|
||||||
|
- `api.ts` — typed fetch wrapper; reads JWT from `localStorage`, prefixes `BASE = '/api/v1'`
|
||||||
|
- `auth.tsx` — `AuthProvider` context; decodes the JWT payload to expose `role` and `volunteerID`
|
||||||
|
- `pages/` — one file per route (`Dashboard`, `Schedules`, `TimeOff`, `Volunteers`, `Login`)
|
||||||
|
- `App.tsx` — `BrowserRouter` with a `ProtectedLayout` that redirects to `/login` when no token is present; admin-only nav links gated on `role === 'admin'`
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
MySQL 8.0 via `github.com/go-sql-driver/mysql`. Schema is defined inline in `internal/db/schema.go` and applied via `CREATE TABLE IF NOT EXISTS` on every startup. No migration framework — additive changes are safe; destructive changes require manual handling.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
HS256 JWT signed with `JWT_SECRET`. Token payload: `{ volunteer_id, role, exp }`. Role is either `volunteer` or `admin`. The `RequireAdmin` middleware enforces admin-only routes server-side.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DB_HOST` | `localhost` | MySQL host |
|
||||||
|
| `DB_PORT` | `3306` | MySQL port |
|
||||||
|
| `DB_USER` | `root` | MySQL username |
|
||||||
|
| `DB_PASSWORD` | `` | MySQL password |
|
||||||
|
| `DB_NAME` | `walkies` | MySQL database name |
|
||||||
|
| `JWT_SECRET` | `change-me-in-production` | HMAC signing key |
|
||||||
|
| `STATIC_DIR` | `./web/dist` | Path to compiled React app |
|
||||||
|
| `PORT` | `8080` | HTTP listen port |
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Stage 1: Build React frontend
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /app/web
|
||||||
|
COPY web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Go backend
|
||||||
|
FROM golang:1.26-alpine AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /walkies ./cmd/server
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /walkies ./walkies
|
||||||
|
COPY --from=frontend /app/web/build ./web/dist
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV STATIC_DIR=/app/web/dist
|
||||||
|
CMD ["./walkies"]
|
||||||
68
README.md
68
README.md
@@ -1,3 +1,71 @@
|
|||||||
# walkies
|
# walkies
|
||||||
|
|
||||||
A web-based application for an animal shelter to manage volunteer scheduling, time off, check-in/check-out, and notifications.
|
A web-based application for an animal shelter to manage volunteer scheduling, time off, check-in/check-out, and notifications.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [Go](https://golang.org/) 1.21+
|
||||||
|
- [Node.js](https://nodejs.org/) 18+
|
||||||
|
- [Task](https://taskfile.dev/) (`brew install go-task` or see [install docs](https://taskfile.dev/installation/))
|
||||||
|
- [Docker](https://www.docker.com/) (optional, for containerised deployment)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run `task` to list all available tasks.
|
||||||
|
|
||||||
|
### Quick start (two terminals)
|
||||||
|
|
||||||
|
**Terminal 1 — backend:**
|
||||||
|
```bash
|
||||||
|
task web:build # build frontend once so the Go server has static files
|
||||||
|
task go:run # starts API server on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2 — frontend dev server:**
|
||||||
|
```bash
|
||||||
|
task web:dev # hot-reload dev server on :3000, proxies /api/v1 → :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
### With live backend reload
|
||||||
|
|
||||||
|
Install [air](https://github.com/air-verse/air) for hot-reloading the Go server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/air-verse/air@latest
|
||||||
|
task web:build
|
||||||
|
task dev:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common tasks
|
||||||
|
|
||||||
|
| Task | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `task build` | Build frontend and Go binary |
|
||||||
|
| `task go:test` | Run Go tests |
|
||||||
|
| `task web:test` | Run frontend tests |
|
||||||
|
| `task go:lint` | Run `go vet` |
|
||||||
|
| `task tidy` | Tidy Go modules |
|
||||||
|
| `task clean` | Remove build artifacts |
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task docker:up # build image and start container
|
||||||
|
task docker:logs # tail logs
|
||||||
|
task docker:down # stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The server is configured via environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | `8080` | HTTP listen port |
|
||||||
|
| `DATABASE_DSN` | `walkies.db` | SQLite file path |
|
||||||
|
| `JWT_SECRET` | `change-me-in-production` | HMAC signing key — **change this** |
|
||||||
|
| `STATIC_DIR` | `./web/build` | Path to compiled React app |
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` to set these locally (the server reads environment variables directly; use a process manager or Docker to inject them).
|
||||||
|
|||||||
128
Taskfile.yml
Normal file
128
Taskfile.yml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
BINARY: walkies
|
||||||
|
WEB_DIR: web
|
||||||
|
STATIC_DIR: "{{.WEB_DIR}}/build"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
desc: List available tasks
|
||||||
|
cmd: task --list
|
||||||
|
|
||||||
|
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
web:install:
|
||||||
|
desc: Install frontend dependencies
|
||||||
|
dir: "{{.WEB_DIR}}"
|
||||||
|
cmd: npm install
|
||||||
|
sources:
|
||||||
|
- package.json
|
||||||
|
generates:
|
||||||
|
- node_modules/.package-lock.json
|
||||||
|
|
||||||
|
web:build:
|
||||||
|
desc: Build frontend for production
|
||||||
|
dir: "{{.WEB_DIR}}"
|
||||||
|
deps: [web:install]
|
||||||
|
cmd: npm run build
|
||||||
|
sources:
|
||||||
|
- src/**/*
|
||||||
|
- public/**/*
|
||||||
|
- package.json
|
||||||
|
- tsconfig.json
|
||||||
|
generates:
|
||||||
|
- build/**/*
|
||||||
|
|
||||||
|
web:dev:
|
||||||
|
desc: Start frontend dev server (hot reload on :3000)
|
||||||
|
dir: "{{.WEB_DIR}}"
|
||||||
|
deps: [web:install]
|
||||||
|
cmd: npm start
|
||||||
|
|
||||||
|
web:test:
|
||||||
|
desc: Run frontend tests
|
||||||
|
dir: "{{.WEB_DIR}}"
|
||||||
|
deps: [web:install]
|
||||||
|
cmd: npm test -- --watchAll=false
|
||||||
|
|
||||||
|
# ── Backend ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
go:build:
|
||||||
|
desc: Build Go binary
|
||||||
|
cmd: go build -o {{.BINARY}} ./cmd/server
|
||||||
|
sources:
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- "{{.BINARY}}"
|
||||||
|
|
||||||
|
go:run:
|
||||||
|
desc: Run Go server (requires built frontend)
|
||||||
|
deps: [web:build]
|
||||||
|
cmd: go run ./cmd/server
|
||||||
|
env:
|
||||||
|
STATIC_DIR: "{{.STATIC_DIR}}"
|
||||||
|
|
||||||
|
go:test:
|
||||||
|
desc: Run all Go tests
|
||||||
|
cmd: go test ./...
|
||||||
|
|
||||||
|
go:test:verbose:
|
||||||
|
desc: Run Go tests with verbose output
|
||||||
|
cmd: go test -v ./...
|
||||||
|
|
||||||
|
go:lint:
|
||||||
|
desc: Run go vet
|
||||||
|
cmd: go vet ./...
|
||||||
|
|
||||||
|
# ── Dev workflow ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
dev:backend:
|
||||||
|
desc: Run backend with live reload via 'air' (install with 'go install github.com/air-verse/air@latest')
|
||||||
|
cmd: air
|
||||||
|
env:
|
||||||
|
STATIC_DIR: "{{.STATIC_DIR}}"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
desc: Start both frontend and backend dev servers concurrently
|
||||||
|
deps: [web:build]
|
||||||
|
cmds:
|
||||||
|
- echo "Starting backend on :8080 and frontend dev server on :3000"
|
||||||
|
# Run backend and frontend in parallel
|
||||||
|
# Use 'task dev:backend' and 'task web:dev' in separate terminals for full hot reload
|
||||||
|
|
||||||
|
# ── Build & Docker ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build frontend and backend
|
||||||
|
deps: [web:build, go:build]
|
||||||
|
|
||||||
|
docker:build:
|
||||||
|
desc: Build Docker image
|
||||||
|
cmd: docker build -t walkies .
|
||||||
|
|
||||||
|
docker:up:
|
||||||
|
desc: Build and start with docker-compose
|
||||||
|
cmd: docker-compose up --build
|
||||||
|
|
||||||
|
docker:down:
|
||||||
|
desc: Stop docker-compose services
|
||||||
|
cmd: docker-compose down
|
||||||
|
|
||||||
|
docker:logs:
|
||||||
|
desc: Tail docker-compose logs
|
||||||
|
cmd: docker-compose logs -f
|
||||||
|
|
||||||
|
# ── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
clean:
|
||||||
|
desc: Remove build artifacts
|
||||||
|
cmds:
|
||||||
|
- rm -f {{.BINARY}}
|
||||||
|
- rm -rf {{.WEB_DIR}}/build
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
desc: Tidy Go modules
|
||||||
|
cmd: go mod tidy
|
||||||
51
cmd/server/main.go
Normal file
51
cmd/server/main.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/db"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Build MySQL DSN from environment variables
|
||||||
|
dbHost := getenv("DB_HOST", "localhost")
|
||||||
|
dbPort := getenv("DB_PORT", "3306")
|
||||||
|
dbUser := getenv("DB_USER", "root")
|
||||||
|
dbPassword := getenv("DB_PASSWORD", "")
|
||||||
|
dbName := getenv("DB_NAME", "walkies")
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPassword, dbHost, dbPort, dbName)
|
||||||
|
|
||||||
|
jwtSecret := getenv("JWT_SECRET", "change-me-in-production")
|
||||||
|
staticDir := getenv("STATIC_DIR", "./web/dist")
|
||||||
|
port := getenv("PORT", "8080")
|
||||||
|
|
||||||
|
database, err := db.Open(dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(context.Background(), database); err != nil {
|
||||||
|
log.Fatalf("migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := server.New(database, jwtSecret, staticDir)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
log.Printf("server listening on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: walkies-mysql
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: walkies
|
||||||
|
MYSQL_USER: walkies
|
||||||
|
MYSQL_PASSWORD: walkies-password
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- walkies-mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
timeout: 20s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
walkies:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_USER: walkies
|
||||||
|
DB_PASSWORD: walkies-password
|
||||||
|
DB_NAME: walkies
|
||||||
|
JWT_SECRET: change-me-in-production
|
||||||
|
PORT: "8080"
|
||||||
|
STATIC_DIR: /app/web/dist
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
walkies-mysql-data:
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module git.unsupervised.ca/walkies
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
33
go.sum
Normal file
33
go.sum
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
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/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=
|
||||||
|
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/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
83
internal/auth/auth.go
Normal file
83
internal/auth/auth.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *sql.DB
|
||||||
|
jwtSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *sql.DB, secret string) *Service {
|
||||||
|
return &Service{db: db, jwtSecret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, email, password string) (string, error) {
|
||||||
|
var id int64
|
||||||
|
var hash, role string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`,
|
||||||
|
email,
|
||||||
|
).Scan(&id, &hash, &role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("query volunteer: %w", err)
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return s.issueToken(id, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueToken(volunteerID int64, role string) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
VolunteerID: volunteerID,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(s.jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Parse(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, fmt.Errorf("unexpected signing method")
|
||||||
|
}
|
||||||
|
return s.jwtSecret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
155
internal/checkin/checkin.go
Normal file
155
internal/checkin/checkin.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package checkin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = fmt.Errorf("check-in not found")
|
||||||
|
ErrAlreadyCheckedIn = fmt.Errorf("already checked in")
|
||||||
|
ErrNotCheckedIn = fmt.Errorf("not checked in")
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckIn struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
ScheduleID *int64 `json:"schedule_id,omitempty"`
|
||||||
|
CheckedInAt time.Time `json:"checked_in_at"`
|
||||||
|
CheckedOutAt *time.Time `json:"checked_out_at,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckInInput struct {
|
||||||
|
ScheduleID *int64 `json:"schedule_id"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckOutInput struct {
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CheckIn(ctx context.Context, volunteerID int64, in CheckInInput) (*CheckIn, error) {
|
||||||
|
// Ensure no active check-in exists
|
||||||
|
var count int
|
||||||
|
s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL`, volunteerID,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil, ErrAlreadyCheckedIn
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO checkins (volunteer_id, schedule_id, notes) VALUES (?, ?, ?)`,
|
||||||
|
volunteerID, in.ScheduleID, in.Notes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert checkin: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CheckOut(ctx context.Context, volunteerID int64, in CheckOutInput) (*CheckIn, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL ORDER BY checked_in_at DESC LIMIT 1`,
|
||||||
|
volunteerID,
|
||||||
|
).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotCheckedIn
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find active checkin: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`UPDATE checkins SET checked_out_at=NOW(), notes=COALESCE(NULLIF(?, ''), notes) WHERE id=?`,
|
||||||
|
in.Notes, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("checkout: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetByID(ctx context.Context, id int64) (*CheckIn, error) {
|
||||||
|
ci := &CheckIn{}
|
||||||
|
var checkedInAt string
|
||||||
|
var checkedOutAt sql.NullString
|
||||||
|
var scheduleID sql.NullInt64
|
||||||
|
var notes sql.NullString
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins WHERE id = ?`, id,
|
||||||
|
).Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, ¬es)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get checkin: %w", err)
|
||||||
|
}
|
||||||
|
ci.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAt)
|
||||||
|
if checkedOutAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", checkedOutAt.String)
|
||||||
|
ci.CheckedOutAt = &t
|
||||||
|
}
|
||||||
|
if scheduleID.Valid {
|
||||||
|
ci.ScheduleID = &scheduleID.Int64
|
||||||
|
}
|
||||||
|
if notes.Valid {
|
||||||
|
ci.Notes = notes.String
|
||||||
|
}
|
||||||
|
return ci, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) History(ctx context.Context, volunteerID int64) ([]CheckIn, error) {
|
||||||
|
query := `SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins`
|
||||||
|
args := []any{}
|
||||||
|
if volunteerID > 0 {
|
||||||
|
query += ` WHERE volunteer_id = ?`
|
||||||
|
args = append(args, volunteerID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY checked_in_at DESC`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list checkins: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var checkins []CheckIn
|
||||||
|
for rows.Next() {
|
||||||
|
var ci CheckIn
|
||||||
|
var checkedInAt string
|
||||||
|
var checkedOutAt sql.NullString
|
||||||
|
var scheduleID sql.NullInt64
|
||||||
|
var notes sql.NullString
|
||||||
|
if err := rows.Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ci.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAt)
|
||||||
|
if checkedOutAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", checkedOutAt.String)
|
||||||
|
ci.CheckedOutAt = &t
|
||||||
|
}
|
||||||
|
if scheduleID.Valid {
|
||||||
|
ci.ScheduleID = &scheduleID.Int64
|
||||||
|
}
|
||||||
|
if notes.Valid {
|
||||||
|
ci.Notes = notes.String
|
||||||
|
}
|
||||||
|
checkins = append(checkins, ci)
|
||||||
|
}
|
||||||
|
return checkins, rows.Err()
|
||||||
|
}
|
||||||
73
internal/checkin/handler.go
Normal file
73
internal/checkin/handler.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package checkin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/checkin
|
||||||
|
func (h *Handler) CheckIn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var in CheckInInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ci, err := h.store.CheckIn(r.Context(), claims.VolunteerID, in)
|
||||||
|
if errors.Is(err, ErrAlreadyCheckedIn) {
|
||||||
|
respond.Error(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not check in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusCreated, ci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/checkout
|
||||||
|
func (h *Handler) CheckOut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var in CheckOutInput
|
||||||
|
json.NewDecoder(r.Body).Decode(&in)
|
||||||
|
ci, err := h.store.CheckOut(r.Context(), claims.VolunteerID, in)
|
||||||
|
if errors.Is(err, ErrNotCheckedIn) {
|
||||||
|
respond.Error(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not check out")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, ci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/checkin/history
|
||||||
|
func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
volunteerID := int64(0)
|
||||||
|
if claims.Role != "admin" {
|
||||||
|
volunteerID = claims.VolunteerID
|
||||||
|
}
|
||||||
|
history, err := h.store.History(r.Context(), volunteerID)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not get history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if history == nil {
|
||||||
|
history = []CheckIn{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, history)
|
||||||
|
}
|
||||||
29
internal/db/db.go
Normal file
29
internal/db/db.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(dsn string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
64
internal/db/schema.go
Normal file
64
internal/db/schema.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
var statements = []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS volunteers (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
|
||||||
|
active TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS schedules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
volunteer_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_volunteer_id (volunteer_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS time_off_requests (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
volunteer_id INT NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected',
|
||||||
|
reviewed_by INT,
|
||||||
|
reviewed_at DATETIME,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (reviewed_by) REFERENCES volunteers(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS checkins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
volunteer_id INT NOT NULL,
|
||||||
|
schedule_id INT,
|
||||||
|
checked_in_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
checked_out_at TIMESTAMP NULL,
|
||||||
|
notes TEXT,
|
||||||
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
|
INDEX idx_schedule_id (schedule_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
volunteer_id INT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
read TINYINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
|
INDEX idx_read (read)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
}
|
||||||
53
internal/notification/handler.go
Normal file
53
internal/notification/handler.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/notifications
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
notifications, err := h.store.ListForVolunteer(r.Context(), claims.VolunteerID)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not list notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if notifications == nil {
|
||||||
|
notifications = []Notification{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/notifications/{id}/read
|
||||||
|
func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.store.MarkRead(r.Context(), id, claims.VolunteerID)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
respond.Error(w, http.StatusNotFound, "notification not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not mark notification as read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, n)
|
||||||
|
}
|
||||||
93
internal/notification/notification.go
Normal file
93
internal/notification/notification.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = fmt.Errorf("notification not found")
|
||||||
|
|
||||||
|
type Notification struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Read bool `json:"read"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(ctx context.Context, volunteerID int64, message string) (*Notification, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO notifications (volunteer_id, message) VALUES (?, ?)`,
|
||||||
|
volunteerID, message,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert notification: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
|
||||||
|
n := &Notification{}
|
||||||
|
var createdAt string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id,
|
||||||
|
).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get notification: %w", err)
|
||||||
|
}
|
||||||
|
n.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
|
||||||
|
volunteerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list notifications: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var notifications []Notification
|
||||||
|
for rows.Next() {
|
||||||
|
var n Notification
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
notifications = append(notifications, n)
|
||||||
|
}
|
||||||
|
return notifications, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
|
||||||
|
id, volunteerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mark read: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
16
internal/respond/respond.go
Normal file
16
internal/respond/respond.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package respond
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func JSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(w http.ResponseWriter, status int, msg string) {
|
||||||
|
JSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
99
internal/schedule/handler.go
Normal file
99
internal/schedule/handler.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package schedule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/schedules
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
volunteerID := int64(0)
|
||||||
|
if claims.Role != "admin" {
|
||||||
|
volunteerID = claims.VolunteerID
|
||||||
|
}
|
||||||
|
schedules, err := h.store.List(r.Context(), volunteerID)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if schedules == nil {
|
||||||
|
schedules = []Schedule{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, schedules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/schedules
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var in CreateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims.Role != "admin" {
|
||||||
|
in.VolunteerID = claims.VolunteerID
|
||||||
|
}
|
||||||
|
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc, err := h.store.Create(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusCreated, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/schedules/{id}
|
||||||
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in UpdateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc, err := h.store.Update(r.Context(), id, in)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
respond.Error(w, http.StatusNotFound, "schedule not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/schedules/{id}
|
||||||
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
154
internal/schedule/schedule.go
Normal file
154
internal/schedule/schedule.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package schedule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = fmt.Errorf("schedule not found")
|
||||||
|
|
||||||
|
type Schedule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartsAt time.Time `json:"starts_at"`
|
||||||
|
EndsAt time.Time `json:"ends_at"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartsAt string `json:"starts_at"`
|
||||||
|
EndsAt string `json:"ends_at"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInput struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
StartsAt *string `json:"starts_at"`
|
||||||
|
EndsAt *string `json:"ends_at"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLayout = "2006-01-02T15:04:05Z"
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert schedule: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||||
|
sc := &Schedule{}
|
||||||
|
var startsAt, endsAt, createdAt, updatedAt string
|
||||||
|
var notes sql.NullString
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id,
|
||||||
|
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get schedule: %w", err)
|
||||||
|
}
|
||||||
|
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||||
|
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||||
|
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
if notes.Valid {
|
||||||
|
sc.Notes = notes.String
|
||||||
|
}
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) {
|
||||||
|
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
|
||||||
|
args := []any{}
|
||||||
|
if volunteerID > 0 {
|
||||||
|
query += ` WHERE volunteer_id = ?`
|
||||||
|
args = append(args, volunteerID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY starts_at`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list schedules: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var schedules []Schedule
|
||||||
|
for rows.Next() {
|
||||||
|
var sc Schedule
|
||||||
|
var startsAt, endsAt, createdAt, updatedAt string
|
||||||
|
var notes sql.NullString
|
||||||
|
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||||
|
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||||
|
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
if notes.Valid {
|
||||||
|
sc.Notes = notes.String
|
||||||
|
}
|
||||||
|
schedules = append(schedules, sc)
|
||||||
|
}
|
||||||
|
return schedules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) {
|
||||||
|
sc, err := s.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := sc.Title
|
||||||
|
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
|
||||||
|
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
|
||||||
|
notes := sc.Notes
|
||||||
|
|
||||||
|
if in.Title != nil {
|
||||||
|
title = *in.Title
|
||||||
|
}
|
||||||
|
if in.StartsAt != nil {
|
||||||
|
startsAt = *in.StartsAt
|
||||||
|
}
|
||||||
|
if in.EndsAt != nil {
|
||||||
|
endsAt = *in.EndsAt
|
||||||
|
}
|
||||||
|
if in.Notes != nil {
|
||||||
|
notes = *in.Notes
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`,
|
||||||
|
title, startsAt, endsAt, notes, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update schedule: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
49
internal/server/middleware/auth.go
Normal file
49
internal/server/middleware/auth.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/auth"
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const claimsKey contextKey = "claims"
|
||||||
|
|
||||||
|
func Authenticate(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
respond.Error(w, http.StatusUnauthorized, "missing or invalid authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := authSvc.Parse(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusUnauthorized, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := ClaimsFromContext(r.Context())
|
||||||
|
if claims == nil || claims.Role != "admin" {
|
||||||
|
respond.Error(w, http.StatusForbidden, "admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaimsFromContext(ctx context.Context) *auth.Claims {
|
||||||
|
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
|
||||||
|
return claims
|
||||||
|
}
|
||||||
83
internal/server/server.go
Normal file
83
internal/server/server.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/auth"
|
||||||
|
"git.unsupervised.ca/walkies/internal/checkin"
|
||||||
|
"git.unsupervised.ca/walkies/internal/notification"
|
||||||
|
"git.unsupervised.ca/walkies/internal/schedule"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
|
"git.unsupervised.ca/walkies/internal/timeoff"
|
||||||
|
"git.unsupervised.ca/walkies/internal/volunteer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||||
|
authSvc := auth.NewService(db, jwtSecret)
|
||||||
|
|
||||||
|
volunteerStore := volunteer.NewStore(db)
|
||||||
|
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
||||||
|
|
||||||
|
scheduleStore := schedule.NewStore(db)
|
||||||
|
scheduleHandler := schedule.NewHandler(scheduleStore)
|
||||||
|
|
||||||
|
timeoffStore := timeoff.NewStore(db)
|
||||||
|
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
||||||
|
|
||||||
|
checkinStore := checkin.NewStore(db)
|
||||||
|
checkinHandler := checkin.NewHandler(checkinStore)
|
||||||
|
|
||||||
|
notificationStore := notification.NewStore(db)
|
||||||
|
notificationHandler := notification.NewHandler(notificationStore)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(chimiddleware.Logger)
|
||||||
|
r.Use(chimiddleware.Recoverer)
|
||||||
|
r.Use(chimiddleware.RealIP)
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
// Public auth endpoints
|
||||||
|
r.Post("/auth/register", volunteerHandler.Register)
|
||||||
|
r.Post("/auth/login", volunteerHandler.Login)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.Authenticate(authSvc))
|
||||||
|
|
||||||
|
// Volunteers
|
||||||
|
r.Get("/volunteers", volunteerHandler.List)
|
||||||
|
r.Get("/volunteers/{id}", volunteerHandler.Get)
|
||||||
|
r.With(middleware.RequireAdmin).Put("/volunteers/{id}", volunteerHandler.Update)
|
||||||
|
|
||||||
|
// Schedules
|
||||||
|
r.Get("/schedules", scheduleHandler.List)
|
||||||
|
r.Post("/schedules", scheduleHandler.Create)
|
||||||
|
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update)
|
||||||
|
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete)
|
||||||
|
|
||||||
|
// Time off
|
||||||
|
r.Get("/timeoff", timeoffHandler.List)
|
||||||
|
r.Post("/timeoff", timeoffHandler.Create)
|
||||||
|
r.With(middleware.RequireAdmin).Put("/timeoff/{id}/review", timeoffHandler.Review)
|
||||||
|
|
||||||
|
// Check-in / check-out
|
||||||
|
r.Post("/checkin", checkinHandler.CheckIn)
|
||||||
|
r.Post("/checkout", checkinHandler.CheckOut)
|
||||||
|
r.Get("/checkin/history", checkinHandler.History)
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
r.Get("/notifications", notificationHandler.List)
|
||||||
|
r.Put("/notifications/{id}/read", notificationHandler.MarkRead)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve static React app for all other routes
|
||||||
|
r.Handle("/*", http.FileServer(http.Dir(staticDir)))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
87
internal/timeoff/handler.go
Normal file
87
internal/timeoff/handler.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package timeoff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/timeoff
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
volunteerID := int64(0)
|
||||||
|
if claims.Role != "admin" {
|
||||||
|
volunteerID = claims.VolunteerID
|
||||||
|
}
|
||||||
|
requests, err := h.store.List(r.Context(), volunteerID)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not list time off requests")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if requests == nil {
|
||||||
|
requests = []Request{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/timeoff
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var in CreateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.StartsAt == "" || in.EndsAt == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := h.store.Create(r.Context(), claims.VolunteerID, in)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not create time off request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusCreated, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/timeoff/{id}/review
|
||||||
|
func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in ReviewInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Status != "approved" && in.Status != "rejected" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "status must be 'approved' or 'rejected'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := h.store.Review(r.Context(), id, claims.VolunteerID, in.Status)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
respond.Error(w, http.StatusNotFound, "time off request not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not review time off request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, req)
|
||||||
|
}
|
||||||
143
internal/timeoff/timeoff.go
Normal file
143
internal/timeoff/timeoff.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package timeoff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = fmt.Errorf("time off request not found")
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
|
StartsAt time.Time `json:"starts_at"`
|
||||||
|
EndsAt time.Time `json:"ends_at"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReviewedBy *int64 `json:"reviewed_by,omitempty"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
StartsAt string `json:"starts_at"`
|
||||||
|
EndsAt string `json:"ends_at"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewInput struct {
|
||||||
|
Status string `json:"status"` // "approved" | "rejected"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(ctx context.Context, volunteerID int64, in CreateInput) (*Request, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO time_off_requests (volunteer_id, starts_at, ends_at, reason) VALUES (?, ?, ?, ?)`,
|
||||||
|
volunteerID, in.StartsAt, in.EndsAt, in.Reason,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert time off request: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetByID(ctx context.Context, id int64) (*Request, error) {
|
||||||
|
req := &Request{}
|
||||||
|
var startsAt, endsAt, createdAt, updatedAt string
|
||||||
|
var reason sql.NullString
|
||||||
|
var reviewedBy sql.NullInt64
|
||||||
|
var reviewedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at
|
||||||
|
FROM time_off_requests WHERE id = ?`, id,
|
||||||
|
).Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get time off request: %w", err)
|
||||||
|
}
|
||||||
|
req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||||
|
req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||||
|
req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
if reason.Valid {
|
||||||
|
req.Reason = reason.String
|
||||||
|
}
|
||||||
|
if reviewedBy.Valid {
|
||||||
|
req.ReviewedBy = &reviewedBy.Int64
|
||||||
|
}
|
||||||
|
if reviewedAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String)
|
||||||
|
req.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Request, error) {
|
||||||
|
query := `SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at FROM time_off_requests`
|
||||||
|
args := []any{}
|
||||||
|
if volunteerID > 0 {
|
||||||
|
query += ` WHERE volunteer_id = ?`
|
||||||
|
args = append(args, volunteerID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY starts_at DESC`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list time off requests: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var requests []Request
|
||||||
|
for rows.Next() {
|
||||||
|
var req Request
|
||||||
|
var startsAt, endsAt, createdAt, updatedAt string
|
||||||
|
var reason sql.NullString
|
||||||
|
var reviewedBy sql.NullInt64
|
||||||
|
var reviewedAt sql.NullString
|
||||||
|
if err := rows.Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||||
|
req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||||
|
req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
if reason.Valid {
|
||||||
|
req.Reason = reason.String
|
||||||
|
}
|
||||||
|
if reviewedBy.Valid {
|
||||||
|
req.ReviewedBy = &reviewedBy.Int64
|
||||||
|
}
|
||||||
|
if reviewedAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String)
|
||||||
|
req.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
requests = append(requests, req)
|
||||||
|
}
|
||||||
|
return requests, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string) (*Request, error) {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE time_off_requests SET status=?, reviewed_by=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?`,
|
||||||
|
status, reviewerID, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("review time off request: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
122
internal/volunteer/handler.go
Normal file
122
internal/volunteer/handler.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package volunteer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.unsupervised.ca/walkies/internal/auth"
|
||||||
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
authSvc *auth.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
|
||||||
|
return &Handler{store: store, authSvc: authSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/auth/register
|
||||||
|
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in CreateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Name == "" || in.Email == "" || in.Password == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Role == "" {
|
||||||
|
in.Role = "volunteer"
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(in.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := h.store.Create(r.Context(), in.Name, in.Email, hash, in.Role)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusConflict, "email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusCreated, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/auth/login
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, map[string]string{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/volunteers
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
volunteers, err := h.store.List(r.Context(), true)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if volunteers == nil {
|
||||||
|
volunteers = []Volunteer{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, volunteers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/volunteers/{id}
|
||||||
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := h.store.GetByID(r.Context(), id)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
respond.Error(w, http.StatusNotFound, "volunteer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/volunteers/{id}
|
||||||
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in UpdateInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := h.store.Update(r.Context(), id, in)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
respond.Error(w, http.StatusNotFound, "volunteer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond.Error(w, http.StatusInternalServerError, "could not update volunteer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
130
internal/volunteer/volunteer.go
Normal file
130
internal/volunteer/volunteer.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package volunteer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = fmt.Errorf("volunteer not found")
|
||||||
|
|
||||||
|
type Volunteer struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInput struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Role *string `json:"role"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(ctx context.Context, name, email, hashedPassword, role string) (*Volunteer, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`,
|
||||||
|
name, email, hashedPassword, role,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert volunteer: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
|
||||||
|
v := &Volunteer{}
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id,
|
||||||
|
).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get volunteer: %w", err)
|
||||||
|
}
|
||||||
|
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error) {
|
||||||
|
query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers`
|
||||||
|
if activeOnly {
|
||||||
|
query += ` WHERE active = 1`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY name`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list volunteers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var volunteers []Volunteer
|
||||||
|
for rows.Next() {
|
||||||
|
var v Volunteer
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
volunteers = append(volunteers, v)
|
||||||
|
}
|
||||||
|
return volunteers, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error) {
|
||||||
|
v, err := s.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
v.Name = *in.Name
|
||||||
|
}
|
||||||
|
if in.Email != nil {
|
||||||
|
v.Email = *in.Email
|
||||||
|
}
|
||||||
|
if in.Role != nil {
|
||||||
|
v.Role = *in.Role
|
||||||
|
}
|
||||||
|
if in.Active != nil {
|
||||||
|
v.Active = *in.Active
|
||||||
|
}
|
||||||
|
activeInt := 0
|
||||||
|
if v.Active {
|
||||||
|
activeInt = 1
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=NOW() WHERE id=?`,
|
||||||
|
v.Name, v.Email, v.Role, activeInt, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update volunteer: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetByID(ctx, id)
|
||||||
|
}
|
||||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
46
web/README.md
Normal file
46
web/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
17359
web/package-lock.json
generated
Normal file
17359
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
web/package.json
Normal file
46
web/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
web/public/index.html
Normal file
43
web/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
web/public/logo192.png
Normal file
BIN
web/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/public/logo512.png
Normal file
BIN
web/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
web/public/manifest.json
Normal file
25
web/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
web/public/robots.txt
Normal file
3
web/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
135
web/src/App.css
Normal file
135
web/src/App.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
|
|
||||||
|
nav {
|
||||||
|
background: #2d6a4f;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand { font-weight: 700; font-size: 1.2rem; margin-right: auto; }
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
nav a.active, nav a:hover { color: white; text-decoration: underline; }
|
||||||
|
|
||||||
|
main { flex: 1; padding: 1.5rem; max-width: 1100px; margin: 0 auto; width: 100%; }
|
||||||
|
|
||||||
|
/* Auth page */
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.auth-page h1 { font-size: 2rem; color: #2d6a4f; }
|
||||||
|
.auth-page form {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page */
|
||||||
|
.page { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.page h2 { font-size: 1.4rem; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.card h3 { margin-bottom: 0.75rem; font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input, textarea, select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus { outline: 2px solid #2d6a4f; border-color: transparent; }
|
||||||
|
textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #2d6a4f;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
button:hover { background: #1e4d38; }
|
||||||
|
.btn-small { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
|
||||||
|
.btn-danger { background: #dc2626; }
|
||||||
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
.btn-link { background: none; color: rgba(255,255,255,0.85); padding: 0; font-size: 0.9rem; }
|
||||||
|
.btn-link:hover { background: none; color: white; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
th { text-align: left; padding: 0.5rem 0.75rem; background: #f9fafb; border-bottom: 2px solid #e5e7eb; }
|
||||||
|
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-pending { color: #b45309; background: #fef3c7; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||||
|
.status-approved { color: #166534; background: #dcfce7; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||||
|
.status-rejected { color: #991b1b; background: #fee2e2; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* Notifications */
|
||||||
|
ul { list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
li { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; }
|
||||||
|
li.unread { font-weight: 600; }
|
||||||
|
li.read { color: #6b7280; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-radius: 9999px;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error { color: #dc2626; font-size: 0.875rem; }
|
||||||
9
web/src/App.test.tsx
Normal file
9
web/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
60
web/src/App.tsx
Normal file
60
web/src/App.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './auth';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Schedules from './pages/Schedules';
|
||||||
|
import TimeOff from './pages/TimeOff';
|
||||||
|
import Volunteers from './pages/Volunteers';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function Nav() {
|
||||||
|
const { role, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<span className="nav-brand">Walkies</span>
|
||||||
|
<NavLink to="/">Dashboard</NavLink>
|
||||||
|
<NavLink to="/schedules">Schedules</NavLink>
|
||||||
|
<NavLink to="/timeoff">Time Off</NavLink>
|
||||||
|
{role === 'admin' && <NavLink to="/volunteers">Volunteers</NavLink>}
|
||||||
|
<button className="btn-link" onClick={logout}>Sign Out</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedLayout() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<Nav />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
<Route path="/timeoff" element={<TimeOff />} />
|
||||||
|
<Route path="/volunteers" element={<Volunteers />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginRoute() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
if (token) return <Navigate to="/" replace />;
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
|
<Route path="/*" element={<ProtectedLayout />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
web/src/api.ts
Normal file
124
web/src/api.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
request<{ token: string }>('POST', '/auth/login', { email, password }),
|
||||||
|
register: (name: string, email: string, password: string, role = 'volunteer') =>
|
||||||
|
request<Volunteer>('POST', '/auth/register', { name, email, password, role }),
|
||||||
|
|
||||||
|
// Volunteers
|
||||||
|
listVolunteers: () => request<Volunteer[]>('GET', '/volunteers'),
|
||||||
|
getVolunteer: (id: number) => request<Volunteer>('GET', `/volunteers/${id}`),
|
||||||
|
updateVolunteer: (id: number, data: Partial<Volunteer>) =>
|
||||||
|
request<Volunteer>('PUT', `/volunteers/${id}`, data),
|
||||||
|
|
||||||
|
// Schedules
|
||||||
|
listSchedules: () => request<Schedule[]>('GET', '/schedules'),
|
||||||
|
createSchedule: (data: CreateScheduleInput) => request<Schedule>('POST', '/schedules', data),
|
||||||
|
updateSchedule: (id: number, data: Partial<CreateScheduleInput>) =>
|
||||||
|
request<Schedule>('PUT', `/schedules/${id}`, data),
|
||||||
|
deleteSchedule: (id: number) => request<void>('DELETE', `/schedules/${id}`),
|
||||||
|
|
||||||
|
// Time off
|
||||||
|
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
||||||
|
createTimeOff: (data: CreateTimeOffInput) => request<TimeOffRequest>('POST', '/timeoff', data),
|
||||||
|
reviewTimeOff: (id: number, status: 'approved' | 'rejected') =>
|
||||||
|
request<TimeOffRequest>('PUT', `/timeoff/${id}/review`, { status }),
|
||||||
|
|
||||||
|
// Check-in / out
|
||||||
|
checkIn: (schedule_id?: number, notes?: string) =>
|
||||||
|
request<CheckIn>('POST', '/checkin', { schedule_id, notes }),
|
||||||
|
checkOut: (notes?: string) => request<CheckIn>('POST', '/checkout', { notes }),
|
||||||
|
getHistory: () => request<CheckIn[]>('GET', '/checkin/history'),
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
listNotifications: () => request<Notification[]>('GET', '/notifications'),
|
||||||
|
markRead: (id: number) => request<Notification>('PUT', `/notifications/${id}/read`, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Volunteer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'volunteer';
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: number;
|
||||||
|
volunteer_id: number;
|
||||||
|
title: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduleInput {
|
||||||
|
volunteer_id?: number;
|
||||||
|
title: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeOffRequest {
|
||||||
|
id: number;
|
||||||
|
volunteer_id: number;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
reason?: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
reviewed_by?: number;
|
||||||
|
reviewed_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTimeOffInput {
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckIn {
|
||||||
|
id: number;
|
||||||
|
volunteer_id: number;
|
||||||
|
schedule_id?: number;
|
||||||
|
checked_in_at: string;
|
||||||
|
checked_out_at?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
volunteer_id: number;
|
||||||
|
message: string;
|
||||||
|
read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
59
web/src/auth.tsx
Normal file
59
web/src/auth.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
role: string | null;
|
||||||
|
volunteerID: number | null;
|
||||||
|
login: (token: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
function parseJWT(token: string): { volunteer_id: number; role: string } | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
const [volunteerID, setVolunteerID] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
const payload = parseJWT(token);
|
||||||
|
setRole(payload?.role ?? null);
|
||||||
|
setVolunteerID(payload?.volunteer_id ?? null);
|
||||||
|
} else {
|
||||||
|
setRole(null);
|
||||||
|
setVolunteerID(null);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
function login(t: string) {
|
||||||
|
localStorage.setItem('token', t);
|
||||||
|
setToken(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, role, volunteerID, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
13
web/src/index.css
Normal file
13
web/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
19
web/src/index.tsx
Normal file
19
web/src/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
1
web/src/logo.svg
Normal file
1
web/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
136
web/src/pages/Dashboard.tsx
Normal file
136
web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { api, CheckIn, Notification, Schedule } from '../api';
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { volunteerID } = useAuth();
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [activeCheckIn, setActiveCheckIn] = useState<CheckIn | null>(null);
|
||||||
|
const [history, setHistory] = useState<CheckIn[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listSchedules().then(setSchedules).catch(() => {});
|
||||||
|
api.listNotifications().then(setNotifications).catch(() => {});
|
||||||
|
api.getHistory().then(data => {
|
||||||
|
setHistory(data);
|
||||||
|
const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID);
|
||||||
|
setActiveCheckIn(active ?? null);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [volunteerID]);
|
||||||
|
|
||||||
|
async function handleCheckIn() {
|
||||||
|
try {
|
||||||
|
const ci = await api.checkIn();
|
||||||
|
setActiveCheckIn(ci);
|
||||||
|
setHistory(prev => [ci, ...prev]);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckOut() {
|
||||||
|
try {
|
||||||
|
const ci = await api.checkOut();
|
||||||
|
setActiveCheckIn(null);
|
||||||
|
setHistory(prev => prev.map(c => c.id === ci.id ? ci : c));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(id: number) {
|
||||||
|
try {
|
||||||
|
await api.markRead(id);
|
||||||
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingSchedules = schedules
|
||||||
|
.filter(s => new Date(s.starts_at) >= new Date())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const unreadNotifications = notifications.filter(n => !n.read);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Check-In Status</h3>
|
||||||
|
{activeCheckIn ? (
|
||||||
|
<div>
|
||||||
|
<p>Checked in at {new Date(activeCheckIn.checked_in_at).toLocaleTimeString()}</p>
|
||||||
|
<button onClick={handleCheckOut}>Check Out</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleCheckIn}>Check In</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Upcoming Shifts</h3>
|
||||||
|
{upcomingSchedules.length === 0 ? (
|
||||||
|
<p>No upcoming shifts.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{upcomingSchedules.map(s => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<strong>{s.title}</strong> — {new Date(s.starts_at).toLocaleString()} to {new Date(s.ends_at).toLocaleString()}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Notifications {unreadNotifications.length > 0 && <span className="badge">{unreadNotifications.length}</span>}</h3>
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<p>No notifications.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{notifications.map(n => (
|
||||||
|
<li key={n.id} className={n.read ? 'read' : 'unread'}>
|
||||||
|
{n.message}
|
||||||
|
{!n.read && (
|
||||||
|
<button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Recent Check-In History</h3>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p>No history yet.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>In</th><th>Out</th><th>Duration</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.slice(0, 10).map(c => {
|
||||||
|
const inTime = new Date(c.checked_in_at);
|
||||||
|
const outTime = c.checked_out_at ? new Date(c.checked_out_at) : null;
|
||||||
|
const duration = outTime
|
||||||
|
? `${Math.round((outTime.getTime() - inTime.getTime()) / 60000)} min`
|
||||||
|
: 'Active';
|
||||||
|
return (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{inTime.toLocaleString()}</td>
|
||||||
|
<td>{outTime ? outTime.toLocaleString() : '—'}</td>
|
||||||
|
<td>{duration}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/pages/Login.tsx
Normal file
43
web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const { token } = await api.login(email, password);
|
||||||
|
login(token);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<h1>Walkies</h1>
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
web/src/pages/Schedules.tsx
Normal file
106
web/src/pages/Schedules.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useState, FormEvent } from 'react';
|
||||||
|
import { api, Schedule } from '../api';
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { role } = useAuth();
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({ title: '', starts_at: '', ends_at: '', notes: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const sc = await api.createSchedule(form);
|
||||||
|
setSchedules(prev => [...prev, sc]);
|
||||||
|
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!window.confirm('Delete this schedule?')) return;
|
||||||
|
try {
|
||||||
|
await api.deleteSchedule(id);
|
||||||
|
setSchedules(prev => prev.filter(s => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>Schedules</h2>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<button onClick={() => setShowForm(v => !v)}>
|
||||||
|
{showForm ? 'Cancel' : 'Add Shift'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form className="card" onSubmit={handleCreate}>
|
||||||
|
<h3>New Shift</h3>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Starts At
|
||||||
|
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ends At
|
||||||
|
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Notes
|
||||||
|
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedules.length === 0 ? (
|
||||||
|
<p>No schedules found.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Starts</th>
|
||||||
|
<th>Ends</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
{role === 'admin' && <th>Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{schedules.map(s => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.title}</td>
|
||||||
|
<td>{new Date(s.starts_at).toLocaleString()}</td>
|
||||||
|
<td>{new Date(s.ends_at).toLocaleString()}</td>
|
||||||
|
<td>{s.notes ?? '—'}</td>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<td>
|
||||||
|
<button className="btn-danger btn-small" onClick={() => handleDelete(s.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
web/src/pages/TimeOff.tsx
Normal file
110
web/src/pages/TimeOff.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useEffect, useState, FormEvent } from 'react';
|
||||||
|
import { api, TimeOffRequest } from '../api';
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
export default function TimeOff() {
|
||||||
|
const { role } = useAuth();
|
||||||
|
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listTimeOff().then(setRequests).catch(() => setError('Could not load requests.'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const req = await api.createTimeOff(form);
|
||||||
|
setRequests(prev => [req, ...prev]);
|
||||||
|
setForm({ starts_at: '', ends_at: '', reason: '' });
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReview(id: number, status: 'approved' | 'rejected') {
|
||||||
|
try {
|
||||||
|
const req = await api.reviewTimeOff(id, status);
|
||||||
|
setRequests(prev => prev.map(r => r.id === id ? req : r));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status: string) => {
|
||||||
|
if (status === 'approved') return 'status-approved';
|
||||||
|
if (status === 'rejected') return 'status-rejected';
|
||||||
|
return 'status-pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>Time Off Requests</h2>
|
||||||
|
<button onClick={() => setShowForm(v => !v)}>
|
||||||
|
{showForm ? 'Cancel' : 'Request Time Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form className="card" onSubmit={handleCreate}>
|
||||||
|
<h3>New Request</h3>
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input type="date" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input type="date" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Reason
|
||||||
|
<textarea value={form.reason} onChange={e => setForm(f => ({ ...f, reason: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<p>No time off requests.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{role === 'admin' && <th>Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.map(r => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{new Date(r.starts_at).toLocaleDateString()}</td>
|
||||||
|
<td>{new Date(r.ends_at).toLocaleDateString()}</td>
|
||||||
|
<td>{r.reason ?? '—'}</td>
|
||||||
|
<td><span className={statusClass(r.status)}>{r.status}</span></td>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<td>
|
||||||
|
{r.status === 'pending' && (
|
||||||
|
<>
|
||||||
|
<button className="btn-small" onClick={() => handleReview(r.id, 'approved')}>Approve</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => handleReview(r.id, 'rejected')}>Reject</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
web/src/pages/Volunteers.tsx
Normal file
57
web/src/pages/Volunteers.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { api, Volunteer } from '../api';
|
||||||
|
|
||||||
|
export default function Volunteers() {
|
||||||
|
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listVolunteers().then(setVolunteers).catch(() => setError('Could not load volunteers.'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleToggleActive(v: Volunteer) {
|
||||||
|
try {
|
||||||
|
const updated = await api.updateVolunteer(v.id, { active: !v.active });
|
||||||
|
setVolunteers(prev => prev.map(vol => vol.id === v.id ? updated : vol));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Volunteers</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{volunteers.length === 0 ? (
|
||||||
|
<p>No volunteers found.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{volunteers.map(v => (
|
||||||
|
<tr key={v.id}>
|
||||||
|
<td>{v.name}</td>
|
||||||
|
<td>{v.email}</td>
|
||||||
|
<td>{v.role}</td>
|
||||||
|
<td>{v.active ? 'Active' : 'Inactive'}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small" onClick={() => handleToggleActive(v)}>
|
||||||
|
{v.active ? 'Deactivate' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
web/src/react-app-env.d.ts
vendored
Normal file
1
web/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
15
web/src/reportWebVitals.ts
Normal file
15
web/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
5
web/src/setupTests.ts
Normal file
5
web/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
26
web/tsconfig.json
Normal file
26
web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user