Compare commits
3 Commits
dc7be0c53a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
67335f0b45
|
|||
|
87caf478df
|
|||
|
55f68c571e
|
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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,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,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,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -18,7 +19,11 @@ func Open(dsn string) (*sql.DB, error) {
|
|||||||
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,7 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
const schema = `
|
var statements = []string{
|
||||||
CREATE TABLE IF NOT EXISTS volunteers (
|
`CREATE TABLE IF NOT EXISTS volunteers (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
@@ -10,9 +10,8 @@ CREATE TABLE IF NOT EXISTS volunteers (
|
|||||||
active TINYINT NOT NULL DEFAULT 1,
|
active TINYINT NOT NULL DEFAULT 1,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
volunteer_id INT NOT NULL,
|
volunteer_id INT NOT NULL,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
@@ -23,9 +22,8 @@ CREATE TABLE IF NOT EXISTS schedules (
|
|||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
INDEX idx_volunteer_id (volunteer_id)
|
INDEX idx_volunteer_id (volunteer_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS time_off_requests (
|
||||||
CREATE TABLE IF NOT EXISTS time_off_requests (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
volunteer_id INT NOT NULL,
|
volunteer_id INT NOT NULL,
|
||||||
starts_at DATETIME NOT NULL,
|
starts_at DATETIME NOT NULL,
|
||||||
@@ -40,9 +38,8 @@ CREATE TABLE IF NOT EXISTS time_off_requests (
|
|||||||
FOREIGN KEY (reviewed_by) REFERENCES volunteers(id) ON DELETE SET NULL,
|
FOREIGN KEY (reviewed_by) REFERENCES volunteers(id) ON DELETE SET NULL,
|
||||||
INDEX idx_volunteer_id (volunteer_id),
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
INDEX idx_status (status)
|
INDEX idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS checkins (
|
||||||
CREATE TABLE IF NOT EXISTS checkins (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
volunteer_id INT NOT NULL,
|
volunteer_id INT NOT NULL,
|
||||||
schedule_id INT,
|
schedule_id INT,
|
||||||
@@ -53,9 +50,8 @@ CREATE TABLE IF NOT EXISTS checkins (
|
|||||||
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE SET NULL,
|
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE SET NULL,
|
||||||
INDEX idx_volunteer_id (volunteer_id),
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
INDEX idx_schedule_id (schedule_id)
|
INDEX idx_schedule_id (schedule_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notifications (
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
volunteer_id INT NOT NULL,
|
volunteer_id INT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -64,5 +60,5 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
INDEX idx_volunteer_id (volunteer_id),
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
INDEX idx_read (read)
|
INDEX idx_read (read)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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