Compare commits

..

6 Commits

Author SHA1 Message Date
67335f0b45 Add claude allow list 2026-03-05 16:26:42 -04:00
87caf478df Conform Go code to project conventions
- Propagate context.Context through all exported store/service methods
  that perform I/O; use QueryContext/ExecContext/QueryRowContext throughout
- Add package-level sentinel errors (ErrNotFound, ErrAlreadyCheckedIn,
  ErrNotCheckedIn) and replace nil,nil returns with explicit errors
- Update handlers to use errors.Is() instead of nil checks, with correct
  HTTP status codes per error type
- Fix SQLite datetime('now') → MySQL NOW() in volunteer, schedule,
  timeoff, and checkin stores
- Refactor db.Migrate to execute schema statements individually (MySQL
  driver does not support multi-statement Exec)
- Fix import grouping in handler files (stdlib, external, internal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 16:20:23 -04:00
55f68c571e Add Claude agents and review-and-commit skill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 15:59:33 -04:00
dc7be0c53a Update Claude to fix reference to sqlite 2026-03-05 14:49:15 -04:00
25fd4a8be7 Update to use MySQL Database 2026-03-05 14:24:04 -04:00
9ecf919d68 Add Taskfile, update README and CLAUDE.md with dev instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 11:37:15 -04:00
30 changed files with 768 additions and 226 deletions

View 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.

View 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
View 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 ./...`

View 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.

View 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.

View 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.

View 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
View 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)"
]
}
}

View 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
View File

@@ -0,0 +1,4 @@
PORT=8080
DATABASE_DSN=walkies.db
JWT_SECRET=change-me-in-production
STATIC_DIR=./web/build

View File

@@ -4,31 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
### Backend (Go) A `Taskfile.yml` is provided — run `task` to list all tasks. Key ones:
```bash ```bash
go build ./... # Build all packages task build # build frontend + Go binary
go run ./cmd/server # Run the server (requires web/build/ to exist) task go:run # build frontend then start server on :8080
go test ./... # Run all tests task web:dev # frontend hot-reload dev server on :3000
go test ./internal/volunteer/... # Run tests for a single package 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
``` ```
### Frontend (React / CRA) For a single Go package test: `go test ./internal/volunteer/...`
```bash
cd web
npm install # Install dependencies
npm run build # Production build → web/build/
npm start # Dev server on :3000 (proxies /api/v1 to :8080)
npm test # Run tests
```
### Docker
```bash
docker-compose up --build # Build and run the full stack
```
## Architecture ## Architecture
### Go Backend ### Go Backend
Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package: Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package:
| Package | Responsibility | | Package | Responsibility |
@@ -39,7 +34,7 @@ Domain-based packaging under `internal/` — each domain owns its models, store
| `internal/checkin` | Check-in / check-out and history | | `internal/checkin` | Check-in / check-out and history |
| `internal/notification` | Per-volunteer notifications | | `internal/notification` | Per-volunteer notifications |
| `internal/auth` | JWT issuance (`auth.Service`) and `HashPassword` | | `internal/auth` | JWT issuance (`auth.Service`) and `HashPassword` |
| `internal/db` | SQLite open + one-shot schema migration (`db.Migrate`) | | `internal/db` | MySQL open + one-shot schema migration (`db.Migrate`) |
| `internal/respond` | `respond.JSON` / `respond.Error` helpers | | `internal/respond` | `respond.JSON` / `respond.Error` helpers |
| `internal/server` | Wires all handlers into a chi router; serves static files at `/` | | `internal/server` | Wires all handlers into a chi router; serves static files at `/` |
| `internal/server/middleware` | `Authenticate` (JWT) and `RequireAdmin` middleware; `ClaimsFromContext` | | `internal/server/middleware` | `Authenticate` (JWT) and `RequireAdmin` middleware; `ClaimsFromContext` |
@@ -49,23 +44,31 @@ Domain-based packaging under `internal/` — each domain owns its models, store
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. 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 ### React Frontend
Standard CRA layout in `web/src/`: Standard CRA layout in `web/src/`:
- `api.ts` — typed fetch wrapper; reads JWT from `localStorage`, prefixes `BASE = '/api/v1'` - `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` - `auth.tsx``AuthProvider` context; decodes the JWT payload to expose `role` and `volunteerID`
- `pages/` — one file per route (`Dashboard`, `Schedules`, `TimeOff`, `Volunteers`, `Login`) - `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'` - `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 ### Data Storage
SQLite via `modernc.org/sqlite` (pure Go, no CGO). 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.
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 ### 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. 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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_DSN` | `walkies.db` | SQLite file path | | `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 | | `JWT_SECRET` | `change-me-in-production` | HMAC signing key |
| `STATIC_DIR` | `./web/dist` | Path to compiled React app | | `STATIC_DIR` | `./web/dist` | Path to compiled React app |
| `PORT` | `8080` | HTTP listen port | | `PORT` | `8080` | HTTP listen port |

View File

@@ -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
View 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

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -11,7 +12,14 @@ import (
) )
func main() { func main() {
dsn := getenv("DATABASE_DSN", "walkies.db") // 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") jwtSecret := getenv("JWT_SECRET", "change-me-in-production")
staticDir := getenv("STATIC_DIR", "./web/dist") staticDir := getenv("STATIC_DIR", "./web/dist")
port := getenv("PORT", "8080") port := getenv("PORT", "8080")
@@ -22,7 +30,7 @@ func main() {
} }
defer database.Close() defer database.Close()
if err := db.Migrate(database); err != nil { if err := db.Migrate(context.Background(), database); err != nil {
log.Fatalf("migrate database: %v", err) log.Fatalf("migrate database: %v", err)
} }

View File

@@ -1,15 +1,37 @@
services: 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: walkies:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
volumes: depends_on:
- walkies-data:/app/data mysql:
condition: service_healthy
environment: environment:
DATABASE_DSN: /app/data/walkies.db DB_HOST: mysql
DB_PORT: "3306"
DB_USER: walkies
DB_PASSWORD: walkies-password
DB_NAME: walkies
JWT_SECRET: change-me-in-production JWT_SECRET: change-me-in-production
PORT: "8080" PORT: "8080"
STATIC_DIR: /app/web/dist STATIC_DIR: /app/web/dist
volumes: volumes:
walkies-data: walkies-mysql-data:

20
go.mod
View File

@@ -3,18 +3,10 @@ module git.unsupervised.ca/walkies
go 1.25.0 go 1.25.0
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 // indirect golang.org/x/crypto v0.48.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
) )
require filippo.io/edwards25519 v1.1.0 // indirect

4
go.sum
View File

@@ -1,7 +1,11 @@
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -27,10 +28,10 @@ func NewService(db *sql.DB, secret string) *Service {
return &Service{db: db, jwtSecret: []byte(secret)} return &Service{db: db, jwtSecret: []byte(secret)}
} }
func (s *Service) Login(email, password string) (string, error) { func (s *Service) Login(ctx context.Context, email, password string) (string, error) {
var id int64 var id int64
var hash, role string var hash, role string
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`, `SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`,
email, email,
).Scan(&id, &hash, &role) ).Scan(&id, &hash, &role)

View File

@@ -1,19 +1,26 @@
package checkin package checkin
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
) )
var (
ErrNotFound = fmt.Errorf("check-in not found")
ErrAlreadyCheckedIn = fmt.Errorf("already checked in")
ErrNotCheckedIn = fmt.Errorf("not checked in")
)
type CheckIn struct { type CheckIn struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
ScheduleID *int64 `json:"schedule_id,omitempty"` ScheduleID *int64 `json:"schedule_id,omitempty"`
CheckedInAt time.Time `json:"checked_in_at"` CheckedInAt time.Time `json:"checked_in_at"`
CheckedOutAt *time.Time `json:"checked_out_at,omitempty"` CheckedOutAt *time.Time `json:"checked_out_at,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
type CheckInInput struct { type CheckInInput struct {
@@ -33,17 +40,17 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) CheckIn(volunteerID int64, in CheckInInput) (*CheckIn, error) { func (s *Store) CheckIn(ctx context.Context, volunteerID int64, in CheckInInput) (*CheckIn, error) {
// Ensure no active check-in exists // Ensure no active check-in exists
var count int var count int
s.db.QueryRow( s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL`, volunteerID, `SELECT COUNT(*) FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL`, volunteerID,
).Scan(&count) ).Scan(&count)
if count > 0 { if count > 0 {
return nil, fmt.Errorf("already checked in") return nil, ErrAlreadyCheckedIn
} }
res, err := s.db.Exec( res, err := s.db.ExecContext(ctx,
`INSERT INTO checkins (volunteer_id, schedule_id, notes) VALUES (?, ?, ?)`, `INSERT INTO checkins (volunteer_id, schedule_id, notes) VALUES (?, ?, ?)`,
volunteerID, in.ScheduleID, in.Notes, volunteerID, in.ScheduleID, in.Notes,
) )
@@ -51,43 +58,43 @@ func (s *Store) CheckIn(volunteerID int64, in CheckInInput) (*CheckIn, error) {
return nil, fmt.Errorf("insert checkin: %w", err) return nil, fmt.Errorf("insert checkin: %w", err)
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) CheckOut(volunteerID int64, in CheckOutInput) (*CheckIn, error) { func (s *Store) CheckOut(ctx context.Context, volunteerID int64, in CheckOutInput) (*CheckIn, error) {
var id int64 var id int64
err := s.db.QueryRow( 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`, `SELECT id FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL ORDER BY checked_in_at DESC LIMIT 1`,
volunteerID, volunteerID,
).Scan(&id) ).Scan(&id)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("not checked in") return nil, ErrNotCheckedIn
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("find active checkin: %w", err) return nil, fmt.Errorf("find active checkin: %w", err)
} }
_, err = s.db.Exec( _, err = s.db.ExecContext(ctx,
`UPDATE checkins SET checked_out_at=datetime('now'), notes=COALESCE(NULLIF(?, ''), notes) WHERE id=?`, `UPDATE checkins SET checked_out_at=NOW(), notes=COALESCE(NULLIF(?, ''), notes) WHERE id=?`,
in.Notes, id, in.Notes, id,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("checkout: %w", err) return nil, fmt.Errorf("checkout: %w", err)
} }
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) GetByID(id int64) (*CheckIn, error) { func (s *Store) GetByID(ctx context.Context, id int64) (*CheckIn, error) {
ci := &CheckIn{} ci := &CheckIn{}
var checkedInAt string var checkedInAt string
var checkedOutAt sql.NullString var checkedOutAt sql.NullString
var scheduleID sql.NullInt64 var scheduleID sql.NullInt64
var notes sql.NullString var notes sql.NullString
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins WHERE id = ?`, id, `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, &notes) ).Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, &notes)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get checkin: %w", err) return nil, fmt.Errorf("get checkin: %w", err)
@@ -106,7 +113,7 @@ func (s *Store) GetByID(id int64) (*CheckIn, error) {
return ci, nil return ci, nil
} }
func (s *Store) History(volunteerID int64) ([]CheckIn, error) { 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` query := `SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins`
args := []any{} args := []any{}
if volunteerID > 0 { if volunteerID > 0 {
@@ -115,7 +122,7 @@ func (s *Store) History(volunteerID int64) ([]CheckIn, error) {
} }
query += ` ORDER BY checked_in_at DESC` query += ` ORDER BY checked_in_at DESC`
rows, err := s.db.Query(query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("list checkins: %w", err) return nil, fmt.Errorf("list checkins: %w", err)
} }

View File

@@ -2,6 +2,7 @@ package checkin
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
@@ -24,11 +25,15 @@ func (h *Handler) CheckIn(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
ci, err := h.store.CheckIn(claims.VolunteerID, in) ci, err := h.store.CheckIn(r.Context(), claims.VolunteerID, in)
if err != nil { if errors.Is(err, ErrAlreadyCheckedIn) {
respond.Error(w, http.StatusConflict, err.Error()) respond.Error(w, http.StatusConflict, err.Error())
return return
} }
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not check in")
return
}
respond.JSON(w, http.StatusCreated, ci) respond.JSON(w, http.StatusCreated, ci)
} }
@@ -37,11 +42,15 @@ func (h *Handler) CheckOut(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
var in CheckOutInput var in CheckOutInput
json.NewDecoder(r.Body).Decode(&in) json.NewDecoder(r.Body).Decode(&in)
ci, err := h.store.CheckOut(claims.VolunteerID, in) ci, err := h.store.CheckOut(r.Context(), claims.VolunteerID, in)
if err != nil { if errors.Is(err, ErrNotCheckedIn) {
respond.Error(w, http.StatusConflict, err.Error()) respond.Error(w, http.StatusConflict, err.Error())
return return
} }
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not check out")
return
}
respond.JSON(w, http.StatusOK, ci) respond.JSON(w, http.StatusOK, ci)
} }
@@ -52,7 +61,7 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
if claims.Role != "admin" { if claims.Role != "admin" {
volunteerID = claims.VolunteerID volunteerID = claims.VolunteerID
} }
history, err := h.store.History(volunteerID) history, err := h.store.History(r.Context(), volunteerID)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get history") respond.Error(w, http.StatusInternalServerError, "could not get history")
return return

View File

@@ -1,27 +1,29 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
_ "modernc.org/sqlite" _ "github.com/go-sql-driver/mysql"
) )
func Open(dsn string) (*sql.DB, error) { func Open(dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("mysql", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open db: %w", err) return nil, fmt.Errorf("open db: %w", err)
} }
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err) return nil, fmt.Errorf("ping db: %w", err)
} }
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil {
return nil, fmt.Errorf("pragma: %w", err)
}
return db, nil return db, nil
} }
func Migrate(db *sql.DB) error { func Migrate(ctx context.Context, db *sql.DB) error {
_, err := db.Exec(schema) for _, stmt := range statements {
return err if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
} }

View File

@@ -1,55 +1,64 @@
package db package db
const schema = ` var statements = []string{
CREATE TABLE IF NOT EXISTS volunteers ( `CREATE TABLE IF NOT EXISTS volunteers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
name TEXT NOT NULL, name VARCHAR(255) NOT NULL,
email TEXT NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password TEXT NOT NULL, password VARCHAR(255) NOT NULL,
role TEXT NOT NULL DEFAULT 'volunteer', -- 'admin' | 'volunteer' role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active INTEGER NOT NULL DEFAULT 1, active TINYINT NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 (
CREATE TABLE IF NOT EXISTS schedules ( id INT AUTO_INCREMENT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT, volunteer_id INT NOT NULL,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), title VARCHAR(255) NOT NULL,
title TEXT NOT NULL, starts_at DATETIME NOT NULL,
starts_at TEXT NOT NULL, ends_at DATETIME NOT NULL,
ends_at TEXT NOT NULL,
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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)
CREATE TABLE IF NOT EXISTS time_off_requests ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
id INTEGER PRIMARY KEY AUTOINCREMENT, `CREATE TABLE IF NOT EXISTS time_off_requests (
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), id INT AUTO_INCREMENT PRIMARY KEY,
starts_at TEXT NOT NULL, volunteer_id INT NOT NULL,
ends_at TEXT NOT NULL, starts_at DATETIME NOT NULL,
ends_at DATETIME NOT NULL,
reason TEXT, reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected' status VARCHAR(50) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected',
reviewed_by INTEGER REFERENCES volunteers(id), reviewed_by INT,
reviewed_at TEXT, reviewed_at DATETIME,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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,
CREATE TABLE IF NOT EXISTS checkins ( INDEX idx_volunteer_id (volunteer_id),
id INTEGER PRIMARY KEY AUTOINCREMENT, INDEX idx_status (status)
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
schedule_id INTEGER REFERENCES schedules(id), `CREATE TABLE IF NOT EXISTS checkins (
checked_in_at TEXT NOT NULL DEFAULT (datetime('now')), id INT AUTO_INCREMENT PRIMARY KEY,
checked_out_at TEXT, volunteer_id INT NOT NULL,
notes TEXT schedule_id INT,
); checked_in_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
checked_out_at TIMESTAMP NULL,
CREATE TABLE IF NOT EXISTS notifications ( notes TEXT,
id INTEGER PRIMARY KEY AUTOINCREMENT, FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE SET NULL,
message TEXT NOT NULL, INDEX idx_volunteer_id (volunteer_id),
read INTEGER NOT NULL DEFAULT 0, INDEX idx_schedule_id (schedule_id)
created_at TEXT NOT NULL DEFAULT (datetime('now')) ) 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`,
}

View File

@@ -1,12 +1,13 @@
package notification package notification
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
) )
type Handler struct { type Handler struct {
@@ -20,7 +21,7 @@ func NewHandler(store *Store) *Handler {
// GET /api/v1/notifications // GET /api/v1/notifications
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
notifications, err := h.store.ListForVolunteer(claims.VolunteerID) notifications, err := h.store.ListForVolunteer(r.Context(), claims.VolunteerID)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list notifications") respond.Error(w, http.StatusInternalServerError, "could not list notifications")
return return
@@ -39,14 +40,14 @@ func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id") respond.Error(w, http.StatusBadRequest, "invalid id")
return return
} }
n, err := h.store.MarkRead(id, claims.VolunteerID) 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 { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not mark notification as read") respond.Error(w, http.StatusInternalServerError, "could not mark notification as read")
return return
} }
if n == nil {
respond.Error(w, http.StatusNotFound, "notification not found")
return
}
respond.JSON(w, http.StatusOK, n) respond.JSON(w, http.StatusOK, n)
} }

View File

@@ -1,12 +1,15 @@
package notification package notification
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
) )
var ErrNotFound = fmt.Errorf("notification not found")
type Notification struct { type Notification struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
@@ -23,8 +26,8 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) Create(volunteerID int64, message string) (*Notification, error) { func (s *Store) Create(ctx context.Context, volunteerID int64, message string) (*Notification, error) {
res, err := s.db.Exec( res, err := s.db.ExecContext(ctx,
`INSERT INTO notifications (volunteer_id, message) VALUES (?, ?)`, `INSERT INTO notifications (volunteer_id, message) VALUES (?, ?)`,
volunteerID, message, volunteerID, message,
) )
@@ -32,17 +35,17 @@ func (s *Store) Create(volunteerID int64, message string) (*Notification, error)
return nil, fmt.Errorf("insert notification: %w", err) return nil, fmt.Errorf("insert notification: %w", err)
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) GetByID(id int64) (*Notification, error) { func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
n := &Notification{} n := &Notification{}
var createdAt string var createdAt string
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id, `SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id,
).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt) ).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get notification: %w", err) return nil, fmt.Errorf("get notification: %w", err)
@@ -51,8 +54,8 @@ func (s *Store) GetByID(id int64) (*Notification, error) {
return n, nil return n, nil
} }
func (s *Store) ListForVolunteer(volunteerID int64) ([]Notification, error) { func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) {
rows, err := s.db.Query( rows, err := s.db.QueryContext(ctx,
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`, `SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
volunteerID, volunteerID,
) )
@@ -74,17 +77,17 @@ func (s *Store) ListForVolunteer(volunteerID int64) ([]Notification, error) {
return notifications, rows.Err() return notifications, rows.Err()
} }
func (s *Store) MarkRead(id, volunteerID int64) (*Notification, error) { func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
result, err := s.db.Exec( result, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`, `UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
id, volunteerID, id, volunteerID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("mark read: %w", err) return nil, fmt.Errorf("mark read: %w", err)
} }
rows, _ := result.RowsAffected() affected, _ := result.RowsAffected()
if rows == 0 { if affected == 0 {
return nil, nil return nil, ErrNotFound
} }
return s.GetByID(id) return s.GetByID(ctx, id)
} }

View File

@@ -2,12 +2,13 @@ package schedule
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
) )
type Handler struct { type Handler struct {
@@ -25,7 +26,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
if claims.Role != "admin" { if claims.Role != "admin" {
volunteerID = claims.VolunteerID volunteerID = claims.VolunteerID
} }
schedules, err := h.store.List(volunteerID) schedules, err := h.store.List(r.Context(), volunteerID)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list schedules") respond.Error(w, http.StatusInternalServerError, "could not list schedules")
return return
@@ -51,7 +52,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required") respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
return return
} }
sc, err := h.store.Create(in) sc, err := h.store.Create(r.Context(), in)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create schedule") respond.Error(w, http.StatusInternalServerError, "could not create schedule")
return return
@@ -71,13 +72,13 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
sc, err := h.store.Update(id, in) sc, err := h.store.Update(r.Context(), id, in)
if err != nil { if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusInternalServerError, "could not update schedule") respond.Error(w, http.StatusNotFound, "schedule not found")
return return
} }
if sc == nil { if err != nil {
respond.Error(w, http.StatusNotFound, "schedule not found") respond.Error(w, http.StatusInternalServerError, "could not update schedule")
return return
} }
respond.JSON(w, http.StatusOK, sc) respond.JSON(w, http.StatusOK, sc)
@@ -90,7 +91,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id") respond.Error(w, http.StatusBadRequest, "invalid id")
return return
} }
if err := h.store.Delete(id); err != nil { if err := h.store.Delete(r.Context(), id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete schedule") respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
return return
} }

View File

@@ -1,12 +1,15 @@
package schedule package schedule
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
) )
var ErrNotFound = fmt.Errorf("schedule not found")
type Schedule struct { type Schedule struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
@@ -43,8 +46,8 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) Create(in CreateInput) (*Schedule, error) { func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) {
res, err := s.db.Exec( res, err := s.db.ExecContext(ctx,
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes, in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
) )
@@ -52,18 +55,18 @@ func (s *Store) Create(in CreateInput) (*Schedule, error) {
return nil, fmt.Errorf("insert schedule: %w", err) return nil, fmt.Errorf("insert schedule: %w", err)
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) GetByID(id int64) (*Schedule, error) { func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) {
sc := &Schedule{} sc := &Schedule{}
var startsAt, endsAt, createdAt, updatedAt string var startsAt, endsAt, createdAt, updatedAt string
var notes sql.NullString var notes sql.NullString
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id, `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, &notes, &createdAt, &updatedAt) ).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, &notes, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get schedule: %w", err) return nil, fmt.Errorf("get schedule: %w", err)
@@ -78,7 +81,7 @@ func (s *Store) GetByID(id int64) (*Schedule, error) {
return sc, nil return sc, nil
} }
func (s *Store) List(volunteerID int64) ([]Schedule, error) { 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` query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
args := []any{} args := []any{}
if volunteerID > 0 { if volunteerID > 0 {
@@ -87,7 +90,7 @@ func (s *Store) List(volunteerID int64) ([]Schedule, error) {
} }
query += ` ORDER BY starts_at` query += ` ORDER BY starts_at`
rows, err := s.db.Query(query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("list schedules: %w", err) return nil, fmt.Errorf("list schedules: %w", err)
} }
@@ -113,10 +116,10 @@ func (s *Store) List(volunteerID int64) ([]Schedule, error) {
return schedules, rows.Err() return schedules, rows.Err()
} }
func (s *Store) Update(id int64, in UpdateInput) (*Schedule, error) { func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) {
sc, err := s.GetByID(id) sc, err := s.GetByID(ctx, id)
if err != nil || sc == nil { if err != nil {
return sc, err return nil, err
} }
title := sc.Title title := sc.Title
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05") startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
@@ -135,17 +138,17 @@ func (s *Store) Update(id int64, in UpdateInput) (*Schedule, error) {
if in.Notes != nil { if in.Notes != nil {
notes = *in.Notes notes = *in.Notes
} }
_, err = s.db.Exec( _, err = s.db.ExecContext(ctx,
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=datetime('now') WHERE id=?`, `UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`,
title, startsAt, endsAt, notes, id, title, startsAt, endsAt, notes, id,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("update schedule: %w", err) return nil, fmt.Errorf("update schedule: %w", err)
} }
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) Delete(id int64) error { func (s *Store) Delete(ctx context.Context, id int64) error {
_, err := s.db.Exec(`DELETE FROM schedules WHERE id = ?`, id) _, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id)
return err return err
} }

View File

@@ -2,12 +2,13 @@ package timeoff
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
) )
type Handler struct { type Handler struct {
@@ -25,7 +26,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
if claims.Role != "admin" { if claims.Role != "admin" {
volunteerID = claims.VolunteerID volunteerID = claims.VolunteerID
} }
requests, err := h.store.List(volunteerID) requests, err := h.store.List(r.Context(), volunteerID)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list time off requests") respond.Error(w, http.StatusInternalServerError, "could not list time off requests")
return return
@@ -48,7 +49,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required") respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
return return
} }
req, err := h.store.Create(claims.VolunteerID, in) req, err := h.store.Create(r.Context(), claims.VolunteerID, in)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create time off request") respond.Error(w, http.StatusInternalServerError, "could not create time off request")
return return
@@ -73,14 +74,14 @@ func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "status must be 'approved' or 'rejected'") respond.Error(w, http.StatusBadRequest, "status must be 'approved' or 'rejected'")
return return
} }
req, err := h.store.Review(id, claims.VolunteerID, in.Status) 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 { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not review time off request") respond.Error(w, http.StatusInternalServerError, "could not review time off request")
return return
} }
if req == nil {
respond.Error(w, http.StatusNotFound, "time off request not found")
return
}
respond.JSON(w, http.StatusOK, req) respond.JSON(w, http.StatusOK, req)
} }

View File

@@ -1,12 +1,15 @@
package timeoff package timeoff
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
) )
var ErrNotFound = fmt.Errorf("time off request not found")
type Request struct { type Request struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
@@ -38,8 +41,8 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) Create(volunteerID int64, in CreateInput) (*Request, error) { func (s *Store) Create(ctx context.Context, volunteerID int64, in CreateInput) (*Request, error) {
res, err := s.db.Exec( res, err := s.db.ExecContext(ctx,
`INSERT INTO time_off_requests (volunteer_id, starts_at, ends_at, reason) VALUES (?, ?, ?, ?)`, `INSERT INTO time_off_requests (volunteer_id, starts_at, ends_at, reason) VALUES (?, ?, ?, ?)`,
volunteerID, in.StartsAt, in.EndsAt, in.Reason, volunteerID, in.StartsAt, in.EndsAt, in.Reason,
) )
@@ -47,22 +50,22 @@ func (s *Store) Create(volunteerID int64, in CreateInput) (*Request, error) {
return nil, fmt.Errorf("insert time off request: %w", err) return nil, fmt.Errorf("insert time off request: %w", err)
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) GetByID(id int64) (*Request, error) { func (s *Store) GetByID(ctx context.Context, id int64) (*Request, error) {
req := &Request{} req := &Request{}
var startsAt, endsAt, createdAt, updatedAt string var startsAt, endsAt, createdAt, updatedAt string
var reason sql.NullString var reason sql.NullString
var reviewedBy sql.NullInt64 var reviewedBy sql.NullInt64
var reviewedAt sql.NullString var reviewedAt sql.NullString
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at `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, FROM time_off_requests WHERE id = ?`, id,
).Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt) ).Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get time off request: %w", err) return nil, fmt.Errorf("get time off request: %w", err)
@@ -84,7 +87,7 @@ func (s *Store) GetByID(id int64) (*Request, error) {
return req, nil return req, nil
} }
func (s *Store) List(volunteerID int64) ([]Request, error) { 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` 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{} args := []any{}
if volunteerID > 0 { if volunteerID > 0 {
@@ -93,7 +96,7 @@ func (s *Store) List(volunteerID int64) ([]Request, error) {
} }
query += ` ORDER BY starts_at DESC` query += ` ORDER BY starts_at DESC`
rows, err := s.db.Query(query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("list time off requests: %w", err) return nil, fmt.Errorf("list time off requests: %w", err)
} }
@@ -128,13 +131,13 @@ func (s *Store) List(volunteerID int64) ([]Request, error) {
return requests, rows.Err() return requests, rows.Err()
} }
func (s *Store) Review(id, reviewerID int64, status string) (*Request, error) { func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string) (*Request, error) {
_, err := s.db.Exec( _, err := s.db.ExecContext(ctx,
`UPDATE time_off_requests SET status=?, reviewed_by=?, reviewed_at=datetime('now'), updated_at=datetime('now') WHERE id=?`, `UPDATE time_off_requests SET status=?, reviewed_by=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?`,
status, reviewerID, id, status, reviewerID, id,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("review time off request: %w", err) return nil, fmt.Errorf("review time off request: %w", err)
} }
return s.GetByID(id) return s.GetByID(ctx, id)
} }

View File

@@ -2,12 +2,13 @@ package volunteer
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/auth" "git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
"github.com/go-chi/chi/v5"
) )
type Handler struct { type Handler struct {
@@ -38,7 +39,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusInternalServerError, "could not hash password") respond.Error(w, http.StatusInternalServerError, "could not hash password")
return return
} }
v, err := h.store.Create(in.Name, in.Email, hash, in.Role) v, err := h.store.Create(r.Context(), in.Name, in.Email, hash, in.Role)
if err != nil { if err != nil {
respond.Error(w, http.StatusConflict, "email already in use") respond.Error(w, http.StatusConflict, "email already in use")
return return
@@ -56,7 +57,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
token, err := h.authSvc.Login(body.Email, body.Password) token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
if err != nil { if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid credentials") respond.Error(w, http.StatusUnauthorized, "invalid credentials")
return return
@@ -66,7 +67,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/volunteers // GET /api/v1/volunteers
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
volunteers, err := h.store.List(true) volunteers, err := h.store.List(r.Context(), true)
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers") respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
return return
@@ -84,13 +85,13 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id") respond.Error(w, http.StatusBadRequest, "invalid id")
return return
} }
v, err := h.store.GetByID(id) v, err := h.store.GetByID(r.Context(), id)
if err != nil { if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusInternalServerError, "could not get volunteer") respond.Error(w, http.StatusNotFound, "volunteer not found")
return return
} }
if v == nil { if err != nil {
respond.Error(w, http.StatusNotFound, "volunteer not found") respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
return return
} }
respond.JSON(w, http.StatusOK, v) respond.JSON(w, http.StatusOK, v)
@@ -108,14 +109,14 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
v, err := h.store.Update(id, in) 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 { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update volunteer") respond.Error(w, http.StatusInternalServerError, "could not update volunteer")
return return
} }
if v == nil {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
respond.JSON(w, http.StatusOK, v) respond.JSON(w, http.StatusOK, v)
} }

View File

@@ -1,12 +1,15 @@
package volunteer package volunteer
import ( import (
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
) )
var ErrNotFound = fmt.Errorf("volunteer not found")
type Volunteer struct { type Volunteer struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -39,8 +42,8 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) Create(name, email, hashedPassword, role string) (*Volunteer, error) { func (s *Store) Create(ctx context.Context, name, email, hashedPassword, role string) (*Volunteer, error) {
res, err := s.db.Exec( res, err := s.db.ExecContext(ctx,
`INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`, `INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`,
name, email, hashedPassword, role, name, email, hashedPassword, role,
) )
@@ -48,17 +51,17 @@ func (s *Store) Create(name, email, hashedPassword, role string) (*Volunteer, er
return nil, fmt.Errorf("insert volunteer: %w", err) return nil, fmt.Errorf("insert volunteer: %w", err)
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return s.GetByID(id) return s.GetByID(ctx, id)
} }
func (s *Store) GetByID(id int64) (*Volunteer, error) { func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
v := &Volunteer{} v := &Volunteer{}
var createdAt, updatedAt string var createdAt, updatedAt string
err := s.db.QueryRow( err := s.db.QueryRowContext(ctx,
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id, `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) ).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get volunteer: %w", err) return nil, fmt.Errorf("get volunteer: %w", err)
@@ -68,14 +71,14 @@ func (s *Store) GetByID(id int64) (*Volunteer, error) {
return v, nil return v, nil
} }
func (s *Store) List(activeOnly bool) ([]Volunteer, error) { func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error) {
query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers` query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers`
if activeOnly { if activeOnly {
query += ` WHERE active = 1` query += ` WHERE active = 1`
} }
query += ` ORDER BY name` query += ` ORDER BY name`
rows, err := s.db.Query(query) rows, err := s.db.QueryContext(ctx, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("list volunteers: %w", err) return nil, fmt.Errorf("list volunteers: %w", err)
} }
@@ -95,10 +98,10 @@ func (s *Store) List(activeOnly bool) ([]Volunteer, error) {
return volunteers, rows.Err() return volunteers, rows.Err()
} }
func (s *Store) Update(id int64, in UpdateInput) (*Volunteer, error) { func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error) {
v, err := s.GetByID(id) v, err := s.GetByID(ctx, id)
if err != nil || v == nil { if err != nil {
return v, err return nil, err
} }
if in.Name != nil { if in.Name != nil {
v.Name = *in.Name v.Name = *in.Name
@@ -116,12 +119,12 @@ func (s *Store) Update(id int64, in UpdateInput) (*Volunteer, error) {
if v.Active { if v.Active {
activeInt = 1 activeInt = 1
} }
_, err = s.db.Exec( _, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=datetime('now') WHERE id=?`, `UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Role, activeInt, id, v.Name, v.Email, v.Role, activeInt, id,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("update volunteer: %w", err) return nil, fmt.Errorf("update volunteer: %w", err)
} }
return s.GetByID(id) return s.GetByID(ctx, id)
} }