Compare commits
6 Commits
28310e5aa1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
67335f0b45
|
|||
|
87caf478df
|
|||
|
55f68c571e
|
|||
|
dc7be0c53a
|
|||
|
25fd4a8be7
|
|||
|
9ecf919d68
|
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
|
||||||
45
CLAUDE.md
45
CLAUDE.md
@@ -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 |
|
||||||
|
|||||||
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
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
20
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
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"`
|
||||||
@@ -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, ¬es)
|
).Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, ¬es)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
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,
|
message TEXT NOT NULL,
|
||||||
read INTEGER NOT NULL DEFAULT 0,
|
read TINYINT NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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`,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¬es, &createdAt, &updatedAt)
|
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user