Scaffold full-stack volunteer scheduling application

Go backend with domain-based packages (volunteer, schedule, timeoff,
checkin, notification), SQLite storage, JWT auth, and chi router.
React TypeScript frontend with routing, auth context, and pages for
all core features. Multi-stage Dockerfile and docker-compose included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 11:25:02 -04:00
parent 64f4563bfa
commit 4989ff1061
49 changed files with 19996 additions and 12 deletions

82
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,82 @@
package auth
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
type Claims struct {
VolunteerID int64 `json:"volunteer_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
db *sql.DB
jwtSecret []byte
}
func NewService(db *sql.DB, secret string) *Service {
return &Service{db: db, jwtSecret: []byte(secret)}
}
func (s *Service) Login(email, password string) (string, error) {
var id int64
var hash, role string
err := s.db.QueryRow(
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`,
email,
).Scan(&id, &hash, &role)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidCredentials
}
if err != nil {
return "", fmt.Errorf("query volunteer: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return "", ErrInvalidCredentials
}
return s.issueToken(id, role)
}
func (s *Service) issueToken(volunteerID int64, role string) (string, error) {
claims := Claims{
VolunteerID: volunteerID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *Service) Parse(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}

148
internal/checkin/checkin.go Normal file
View File

@@ -0,0 +1,148 @@
package checkin
import (
"database/sql"
"errors"
"fmt"
"time"
)
type CheckIn struct {
ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"`
ScheduleID *int64 `json:"schedule_id,omitempty"`
CheckedInAt time.Time `json:"checked_in_at"`
CheckedOutAt *time.Time `json:"checked_out_at,omitempty"`
Notes string `json:"notes,omitempty"`
}
type CheckInInput struct {
ScheduleID *int64 `json:"schedule_id"`
Notes string `json:"notes"`
}
type CheckOutInput struct {
Notes string `json:"notes"`
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) CheckIn(volunteerID int64, in CheckInInput) (*CheckIn, error) {
// Ensure no active check-in exists
var count int
s.db.QueryRow(
`SELECT COUNT(*) FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL`, volunteerID,
).Scan(&count)
if count > 0 {
return nil, fmt.Errorf("already checked in")
}
res, err := s.db.Exec(
`INSERT INTO checkins (volunteer_id, schedule_id, notes) VALUES (?, ?, ?)`,
volunteerID, in.ScheduleID, in.Notes,
)
if err != nil {
return nil, fmt.Errorf("insert checkin: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(id)
}
func (s *Store) CheckOut(volunteerID int64, in CheckOutInput) (*CheckIn, error) {
var id int64
err := s.db.QueryRow(
`SELECT id FROM checkins WHERE volunteer_id = ? AND checked_out_at IS NULL ORDER BY checked_in_at DESC LIMIT 1`,
volunteerID,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("not checked in")
}
if err != nil {
return nil, fmt.Errorf("find active checkin: %w", err)
}
_, err = s.db.Exec(
`UPDATE checkins SET checked_out_at=datetime('now'), notes=COALESCE(NULLIF(?, ''), notes) WHERE id=?`,
in.Notes, id,
)
if err != nil {
return nil, fmt.Errorf("checkout: %w", err)
}
return s.GetByID(id)
}
func (s *Store) GetByID(id int64) (*CheckIn, error) {
ci := &CheckIn{}
var checkedInAt string
var checkedOutAt sql.NullString
var scheduleID sql.NullInt64
var notes sql.NullString
err := s.db.QueryRow(
`SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins WHERE id = ?`, id,
).Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, &notes)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get checkin: %w", err)
}
ci.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAt)
if checkedOutAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", checkedOutAt.String)
ci.CheckedOutAt = &t
}
if scheduleID.Valid {
ci.ScheduleID = &scheduleID.Int64
}
if notes.Valid {
ci.Notes = notes.String
}
return ci, nil
}
func (s *Store) History(volunteerID int64) ([]CheckIn, error) {
query := `SELECT id, volunteer_id, schedule_id, checked_in_at, checked_out_at, notes FROM checkins`
args := []any{}
if volunteerID > 0 {
query += ` WHERE volunteer_id = ?`
args = append(args, volunteerID)
}
query += ` ORDER BY checked_in_at DESC`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list checkins: %w", err)
}
defer rows.Close()
var checkins []CheckIn
for rows.Next() {
var ci CheckIn
var checkedInAt string
var checkedOutAt sql.NullString
var scheduleID sql.NullInt64
var notes sql.NullString
if err := rows.Scan(&ci.ID, &ci.VolunteerID, &scheduleID, &checkedInAt, &checkedOutAt, &notes); err != nil {
return nil, err
}
ci.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAt)
if checkedOutAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", checkedOutAt.String)
ci.CheckedOutAt = &t
}
if scheduleID.Valid {
ci.ScheduleID = &scheduleID.Int64
}
if notes.Valid {
ci.Notes = notes.String
}
checkins = append(checkins, ci)
}
return checkins, rows.Err()
}

View File

@@ -0,0 +1,64 @@
package checkin
import (
"encoding/json"
"net/http"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
)
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
// POST /api/v1/checkin
func (h *Handler) CheckIn(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CheckInInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
ci, err := h.store.CheckIn(claims.VolunteerID, in)
if err != nil {
respond.Error(w, http.StatusConflict, err.Error())
return
}
respond.JSON(w, http.StatusCreated, ci)
}
// POST /api/v1/checkout
func (h *Handler) CheckOut(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CheckOutInput
json.NewDecoder(r.Body).Decode(&in)
ci, err := h.store.CheckOut(claims.VolunteerID, in)
if err != nil {
respond.Error(w, http.StatusConflict, err.Error())
return
}
respond.JSON(w, http.StatusOK, ci)
}
// GET /api/v1/checkin/history
func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
volunteerID := int64(0)
if claims.Role != "admin" {
volunteerID = claims.VolunteerID
}
history, err := h.store.History(volunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get history")
return
}
if history == nil {
history = []CheckIn{}
}
respond.JSON(w, http.StatusOK, history)
}

27
internal/db/db.go Normal file
View File

@@ -0,0 +1,27 @@
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
func Open(dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil {
return nil, fmt.Errorf("pragma: %w", err)
}
return db, nil
}
func Migrate(db *sql.DB) error {
_, err := db.Exec(schema)
return err
}

55
internal/db/schema.go Normal file
View File

@@ -0,0 +1,55 @@
package db
const schema = `
CREATE TABLE IF NOT EXISTS volunteers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'volunteer', -- 'admin' | 'volunteer'
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id),
title TEXT NOT NULL,
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS time_off_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id),
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected'
reviewed_by INTEGER REFERENCES volunteers(id),
reviewed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id),
schedule_id INTEGER REFERENCES schedules(id),
checked_in_at TEXT NOT NULL DEFAULT (datetime('now')),
checked_out_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id),
message TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`

View File

@@ -0,0 +1,52 @@
package notification
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
)
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
// GET /api/v1/notifications
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
notifications, err := h.store.ListForVolunteer(claims.VolunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list notifications")
return
}
if notifications == nil {
notifications = []Notification{}
}
respond.JSON(w, http.StatusOK, notifications)
}
// PUT /api/v1/notifications/{id}/read
func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
n, err := h.store.MarkRead(id, claims.VolunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not mark notification as read")
return
}
if n == nil {
respond.Error(w, http.StatusNotFound, "notification not found")
return
}
respond.JSON(w, http.StatusOK, n)
}

View File

@@ -0,0 +1,90 @@
package notification
import (
"database/sql"
"errors"
"fmt"
"time"
)
type Notification struct {
ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"`
Message string `json:"message"`
Read bool `json:"read"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(volunteerID int64, message string) (*Notification, error) {
res, err := s.db.Exec(
`INSERT INTO notifications (volunteer_id, message) VALUES (?, ?)`,
volunteerID, message,
)
if err != nil {
return nil, fmt.Errorf("insert notification: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(id)
}
func (s *Store) GetByID(id int64) (*Notification, error) {
n := &Notification{}
var createdAt string
err := s.db.QueryRow(
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id,
).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get notification: %w", err)
}
n.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
return n, nil
}
func (s *Store) ListForVolunteer(volunteerID int64) ([]Notification, error) {
rows, err := s.db.Query(
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
volunteerID,
)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var n Notification
var createdAt string
if err := rows.Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt); err != nil {
return nil, err
}
n.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
notifications = append(notifications, n)
}
return notifications, rows.Err()
}
func (s *Store) MarkRead(id, volunteerID int64) (*Notification, error) {
result, err := s.db.Exec(
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
id, volunteerID,
)
if err != nil {
return nil, fmt.Errorf("mark read: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, nil
}
return s.GetByID(id)
}

View File

@@ -0,0 +1,16 @@
package respond
import (
"encoding/json"
"net/http"
)
func JSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func Error(w http.ResponseWriter, status int, msg string) {
JSON(w, status, map[string]string{"error": msg})
}

View File

@@ -0,0 +1,98 @@
package schedule
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
)
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
// GET /api/v1/schedules
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
volunteerID := int64(0)
if claims.Role != "admin" {
volunteerID = claims.VolunteerID
}
schedules, err := h.store.List(volunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
return
}
if schedules == nil {
schedules = []Schedule{}
}
respond.JSON(w, http.StatusOK, schedules)
}
// POST /api/v1/schedules
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if claims.Role != "admin" {
in.VolunteerID = claims.VolunteerID
}
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
return
}
sc, err := h.store.Create(in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
return
}
respond.JSON(w, http.StatusCreated, sc)
}
// PUT /api/v1/schedules/{id}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
sc, err := h.store.Update(id, in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
return
}
if sc == nil {
respond.Error(w, http.StatusNotFound, "schedule not found")
return
}
respond.JSON(w, http.StatusOK, sc)
}
// DELETE /api/v1/schedules/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.store.Delete(id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,151 @@
package schedule
import (
"database/sql"
"errors"
"fmt"
"time"
)
type Schedule struct {
ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"`
Title string `json:"title"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateInput struct {
VolunteerID int64 `json:"volunteer_id"`
Title string `json:"title"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Notes string `json:"notes"`
}
type UpdateInput struct {
Title *string `json:"title"`
StartsAt *string `json:"starts_at"`
EndsAt *string `json:"ends_at"`
Notes *string `json:"notes"`
}
const timeLayout = "2006-01-02T15:04:05Z"
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(in CreateInput) (*Schedule, error) {
res, err := s.db.Exec(
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
)
if err != nil {
return nil, fmt.Errorf("insert schedule: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(id)
}
func (s *Store) GetByID(id int64) (*Schedule, error) {
sc := &Schedule{}
var startsAt, endsAt, createdAt, updatedAt string
var notes sql.NullString
err := s.db.QueryRow(
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id,
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, &notes, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get schedule: %w", err)
}
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if notes.Valid {
sc.Notes = notes.String
}
return sc, nil
}
func (s *Store) List(volunteerID int64) ([]Schedule, error) {
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
args := []any{}
if volunteerID > 0 {
query += ` WHERE volunteer_id = ?`
args = append(args, volunteerID)
}
query += ` ORDER BY starts_at`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list schedules: %w", err)
}
defer rows.Close()
var schedules []Schedule
for rows.Next() {
var sc Schedule
var startsAt, endsAt, createdAt, updatedAt string
var notes sql.NullString
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, &notes, &createdAt, &updatedAt); err != nil {
return nil, err
}
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if notes.Valid {
sc.Notes = notes.String
}
schedules = append(schedules, sc)
}
return schedules, rows.Err()
}
func (s *Store) Update(id int64, in UpdateInput) (*Schedule, error) {
sc, err := s.GetByID(id)
if err != nil || sc == nil {
return sc, err
}
title := sc.Title
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
notes := sc.Notes
if in.Title != nil {
title = *in.Title
}
if in.StartsAt != nil {
startsAt = *in.StartsAt
}
if in.EndsAt != nil {
endsAt = *in.EndsAt
}
if in.Notes != nil {
notes = *in.Notes
}
_, err = s.db.Exec(
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=datetime('now') WHERE id=?`,
title, startsAt, endsAt, notes, id,
)
if err != nil {
return nil, fmt.Errorf("update schedule: %w", err)
}
return s.GetByID(id)
}
func (s *Store) Delete(id int64) error {
_, err := s.db.Exec(`DELETE FROM schedules WHERE id = ?`, id)
return err
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"context"
"net/http"
"strings"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
)
type contextKey string
const claimsKey contextKey = "claims"
func Authenticate(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
respond.Error(w, http.StatusUnauthorized, "missing or invalid authorization header")
return
}
claims, err := authSvc.Parse(strings.TrimPrefix(header, "Bearer "))
if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid token")
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := ClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
respond.Error(w, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
func ClaimsFromContext(ctx context.Context) *auth.Claims {
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
return claims
}

83
internal/server/server.go Normal file
View File

@@ -0,0 +1,83 @@
package server
import (
"database/sql"
"net/http"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/checkin"
"git.unsupervised.ca/walkies/internal/notification"
"git.unsupervised.ca/walkies/internal/schedule"
"git.unsupervised.ca/walkies/internal/server/middleware"
"git.unsupervised.ca/walkies/internal/timeoff"
"git.unsupervised.ca/walkies/internal/volunteer"
)
func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
authSvc := auth.NewService(db, jwtSecret)
volunteerStore := volunteer.NewStore(db)
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
scheduleStore := schedule.NewStore(db)
scheduleHandler := schedule.NewHandler(scheduleStore)
timeoffStore := timeoff.NewStore(db)
timeoffHandler := timeoff.NewHandler(timeoffStore)
checkinStore := checkin.NewStore(db)
checkinHandler := checkin.NewHandler(checkinStore)
notificationStore := notification.NewStore(db)
notificationHandler := notification.NewHandler(notificationStore)
r := chi.NewRouter()
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
r.Use(chimiddleware.RealIP)
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Public auth endpoints
r.Post("/auth/register", volunteerHandler.Register)
r.Post("/auth/login", volunteerHandler.Login)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
// Volunteers
r.Get("/volunteers", volunteerHandler.List)
r.Get("/volunteers/{id}", volunteerHandler.Get)
r.With(middleware.RequireAdmin).Put("/volunteers/{id}", volunteerHandler.Update)
// Schedules
r.Get("/schedules", scheduleHandler.List)
r.Post("/schedules", scheduleHandler.Create)
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update)
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete)
// Time off
r.Get("/timeoff", timeoffHandler.List)
r.Post("/timeoff", timeoffHandler.Create)
r.With(middleware.RequireAdmin).Put("/timeoff/{id}/review", timeoffHandler.Review)
// Check-in / check-out
r.Post("/checkin", checkinHandler.CheckIn)
r.Post("/checkout", checkinHandler.CheckOut)
r.Get("/checkin/history", checkinHandler.History)
// Notifications
r.Get("/notifications", notificationHandler.List)
r.Put("/notifications/{id}/read", notificationHandler.MarkRead)
})
})
// Serve static React app for all other routes
r.Handle("/*", http.FileServer(http.Dir(staticDir)))
return r
}

View File

@@ -0,0 +1,86 @@
package timeoff
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
)
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
// GET /api/v1/timeoff
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
volunteerID := int64(0)
if claims.Role != "admin" {
volunteerID = claims.VolunteerID
}
requests, err := h.store.List(volunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list time off requests")
return
}
if requests == nil {
requests = []Request{}
}
respond.JSON(w, http.StatusOK, requests)
}
// POST /api/v1/timeoff
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.StartsAt == "" || in.EndsAt == "" {
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
return
}
req, err := h.store.Create(claims.VolunteerID, in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create time off request")
return
}
respond.JSON(w, http.StatusCreated, req)
}
// PUT /api/v1/timeoff/{id}/review
func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
var in ReviewInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Status != "approved" && in.Status != "rejected" {
respond.Error(w, http.StatusBadRequest, "status must be 'approved' or 'rejected'")
return
}
req, err := h.store.Review(id, claims.VolunteerID, in.Status)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not review time off request")
return
}
if req == nil {
respond.Error(w, http.StatusNotFound, "time off request not found")
return
}
respond.JSON(w, http.StatusOK, req)
}

140
internal/timeoff/timeoff.go Normal file
View File

@@ -0,0 +1,140 @@
package timeoff
import (
"database/sql"
"errors"
"fmt"
"time"
)
type Request struct {
ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
Reason string `json:"reason,omitempty"`
Status string `json:"status"`
ReviewedBy *int64 `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Reason string `json:"reason"`
}
type ReviewInput struct {
Status string `json:"status"` // "approved" | "rejected"
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(volunteerID int64, in CreateInput) (*Request, error) {
res, err := s.db.Exec(
`INSERT INTO time_off_requests (volunteer_id, starts_at, ends_at, reason) VALUES (?, ?, ?, ?)`,
volunteerID, in.StartsAt, in.EndsAt, in.Reason,
)
if err != nil {
return nil, fmt.Errorf("insert time off request: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(id)
}
func (s *Store) GetByID(id int64) (*Request, error) {
req := &Request{}
var startsAt, endsAt, createdAt, updatedAt string
var reason sql.NullString
var reviewedBy sql.NullInt64
var reviewedAt sql.NullString
err := s.db.QueryRow(
`SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at
FROM time_off_requests WHERE id = ?`, id,
).Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get time off request: %w", err)
}
req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if reason.Valid {
req.Reason = reason.String
}
if reviewedBy.Valid {
req.ReviewedBy = &reviewedBy.Int64
}
if reviewedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String)
req.ReviewedAt = &t
}
return req, nil
}
func (s *Store) List(volunteerID int64) ([]Request, error) {
query := `SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at FROM time_off_requests`
args := []any{}
if volunteerID > 0 {
query += ` WHERE volunteer_id = ?`
args = append(args, volunteerID)
}
query += ` ORDER BY starts_at DESC`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list time off requests: %w", err)
}
defer rows.Close()
var requests []Request
for rows.Next() {
var req Request
var startsAt, endsAt, createdAt, updatedAt string
var reason sql.NullString
var reviewedBy sql.NullInt64
var reviewedAt sql.NullString
if err := rows.Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt); err != nil {
return nil, err
}
req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if reason.Valid {
req.Reason = reason.String
}
if reviewedBy.Valid {
req.ReviewedBy = &reviewedBy.Int64
}
if reviewedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String)
req.ReviewedAt = &t
}
requests = append(requests, req)
}
return requests, rows.Err()
}
func (s *Store) Review(id, reviewerID int64, status string) (*Request, error) {
_, err := s.db.Exec(
`UPDATE time_off_requests SET status=?, reviewed_by=?, reviewed_at=datetime('now'), updated_at=datetime('now') WHERE id=?`,
status, reviewerID, id,
)
if err != nil {
return nil, fmt.Errorf("review time off request: %w", err)
}
return s.GetByID(id)
}

View File

@@ -0,0 +1,121 @@
package volunteer
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
)
type Handler struct {
store *Store
authSvc *auth.Service
}
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/register
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" || in.Password == "" {
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
return
}
if in.Role == "" {
in.Role = "volunteer"
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Create(in.Name, in.Email, hash, in.Role)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, v)
}
// POST /api/v1/auth/login
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
token, err := h.authSvc.Login(body.Email, body.Password)
if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
respond.JSON(w, http.StatusOK, map[string]string{"token": token})
}
// GET /api/v1/volunteers
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
volunteers, err := h.store.List(true)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
return
}
if volunteers == nil {
volunteers = []Volunteer{}
}
respond.JSON(w, http.StatusOK, volunteers)
}
// GET /api/v1/volunteers/{id}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
v, err := h.store.GetByID(id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
return
}
if v == nil {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
respond.JSON(w, http.StatusOK, v)
}
// PUT /api/v1/volunteers/{id}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
v, err := h.store.Update(id, in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update volunteer")
return
}
if v == nil {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
respond.JSON(w, http.StatusOK, v)
}

View File

@@ -0,0 +1,127 @@
package volunteer
import (
"database/sql"
"errors"
"fmt"
"time"
)
type Volunteer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateInput struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
type UpdateInput struct {
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Active *bool `json:"active"`
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(name, email, hashedPassword, role string) (*Volunteer, error) {
res, err := s.db.Exec(
`INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`,
name, email, hashedPassword, role,
)
if err != nil {
return nil, fmt.Errorf("insert volunteer: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(id)
}
func (s *Store) GetByID(id int64) (*Volunteer, error) {
v := &Volunteer{}
var createdAt, updatedAt string
err := s.db.QueryRow(
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id,
).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get volunteer: %w", err)
}
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return v, nil
}
func (s *Store) List(activeOnly bool) ([]Volunteer, error) {
query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers`
if activeOnly {
query += ` WHERE active = 1`
}
query += ` ORDER BY name`
rows, err := s.db.Query(query)
if err != nil {
return nil, fmt.Errorf("list volunteers: %w", err)
}
defer rows.Close()
var volunteers []Volunteer
for rows.Next() {
var v Volunteer
var createdAt, updatedAt string
if err := rows.Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt); err != nil {
return nil, err
}
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
volunteers = append(volunteers, v)
}
return volunteers, rows.Err()
}
func (s *Store) Update(id int64, in UpdateInput) (*Volunteer, error) {
v, err := s.GetByID(id)
if err != nil || v == nil {
return v, err
}
if in.Name != nil {
v.Name = *in.Name
}
if in.Email != nil {
v.Email = *in.Email
}
if in.Role != nil {
v.Role = *in.Role
}
if in.Active != nil {
v.Active = *in.Active
}
activeInt := 0
if v.Active {
activeInt = 1
}
_, err = s.db.Exec(
`UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=datetime('now') WHERE id=?`,
v.Name, v.Email, v.Role, activeInt, id,
)
if err != nil {
return nil, fmt.Errorf("update volunteer: %w", err)
}
return s.GetByID(id)
}