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:
82
internal/auth/auth.go
Normal file
82
internal/auth/auth.go
Normal 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
148
internal/checkin/checkin.go
Normal 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, ¬es)
|
||||
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, ¬es); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ci.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAt)
|
||||
if checkedOutAt.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", checkedOutAt.String)
|
||||
ci.CheckedOutAt = &t
|
||||
}
|
||||
if scheduleID.Valid {
|
||||
ci.ScheduleID = &scheduleID.Int64
|
||||
}
|
||||
if notes.Valid {
|
||||
ci.Notes = notes.String
|
||||
}
|
||||
checkins = append(checkins, ci)
|
||||
}
|
||||
return checkins, rows.Err()
|
||||
}
|
||||
64
internal/checkin/handler.go
Normal file
64
internal/checkin/handler.go
Normal 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
27
internal/db/db.go
Normal 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
55
internal/db/schema.go
Normal 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'))
|
||||
);
|
||||
`
|
||||
52
internal/notification/handler.go
Normal file
52
internal/notification/handler.go
Normal 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)
|
||||
}
|
||||
90
internal/notification/notification.go
Normal file
90
internal/notification/notification.go
Normal 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)
|
||||
}
|
||||
16
internal/respond/respond.go
Normal file
16
internal/respond/respond.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package respond
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func JSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
98
internal/schedule/handler.go
Normal file
98
internal/schedule/handler.go
Normal 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)
|
||||
}
|
||||
151
internal/schedule/schedule.go
Normal file
151
internal/schedule/schedule.go
Normal 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, ¬es, &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, ¬es, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if notes.Valid {
|
||||
sc.Notes = notes.String
|
||||
}
|
||||
schedules = append(schedules, sc)
|
||||
}
|
||||
return schedules, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Update(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
|
||||
}
|
||||
49
internal/server/middleware/auth.go
Normal file
49
internal/server/middleware/auth.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/auth"
|
||||
"git.unsupervised.ca/walkies/internal/respond"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
func Authenticate(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
respond.Error(w, http.StatusUnauthorized, "missing or invalid authorization header")
|
||||
return
|
||||
}
|
||||
claims, err := authSvc.Parse(strings.TrimPrefix(header, "Bearer "))
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := ClaimsFromContext(r.Context())
|
||||
if claims == nil || claims.Role != "admin" {
|
||||
respond.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func ClaimsFromContext(ctx context.Context) *auth.Claims {
|
||||
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
|
||||
return claims
|
||||
}
|
||||
83
internal/server/server.go
Normal file
83
internal/server/server.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/auth"
|
||||
"git.unsupervised.ca/walkies/internal/checkin"
|
||||
"git.unsupervised.ca/walkies/internal/notification"
|
||||
"git.unsupervised.ca/walkies/internal/schedule"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"git.unsupervised.ca/walkies/internal/timeoff"
|
||||
"git.unsupervised.ca/walkies/internal/volunteer"
|
||||
)
|
||||
|
||||
func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
authSvc := auth.NewService(db, jwtSecret)
|
||||
|
||||
volunteerStore := volunteer.NewStore(db)
|
||||
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
||||
|
||||
scheduleStore := schedule.NewStore(db)
|
||||
scheduleHandler := schedule.NewHandler(scheduleStore)
|
||||
|
||||
timeoffStore := timeoff.NewStore(db)
|
||||
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
||||
|
||||
checkinStore := checkin.NewStore(db)
|
||||
checkinHandler := checkin.NewHandler(checkinStore)
|
||||
|
||||
notificationStore := notification.NewStore(db)
|
||||
notificationHandler := notification.NewHandler(notificationStore)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(chimiddleware.RealIP)
|
||||
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Public auth endpoints
|
||||
r.Post("/auth/register", volunteerHandler.Register)
|
||||
r.Post("/auth/login", volunteerHandler.Login)
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Authenticate(authSvc))
|
||||
|
||||
// Volunteers
|
||||
r.Get("/volunteers", volunteerHandler.List)
|
||||
r.Get("/volunteers/{id}", volunteerHandler.Get)
|
||||
r.With(middleware.RequireAdmin).Put("/volunteers/{id}", volunteerHandler.Update)
|
||||
|
||||
// Schedules
|
||||
r.Get("/schedules", scheduleHandler.List)
|
||||
r.Post("/schedules", scheduleHandler.Create)
|
||||
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update)
|
||||
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete)
|
||||
|
||||
// Time off
|
||||
r.Get("/timeoff", timeoffHandler.List)
|
||||
r.Post("/timeoff", timeoffHandler.Create)
|
||||
r.With(middleware.RequireAdmin).Put("/timeoff/{id}/review", timeoffHandler.Review)
|
||||
|
||||
// Check-in / check-out
|
||||
r.Post("/checkin", checkinHandler.CheckIn)
|
||||
r.Post("/checkout", checkinHandler.CheckOut)
|
||||
r.Get("/checkin/history", checkinHandler.History)
|
||||
|
||||
// Notifications
|
||||
r.Get("/notifications", notificationHandler.List)
|
||||
r.Put("/notifications/{id}/read", notificationHandler.MarkRead)
|
||||
})
|
||||
})
|
||||
|
||||
// Serve static React app for all other routes
|
||||
r.Handle("/*", http.FileServer(http.Dir(staticDir)))
|
||||
|
||||
return r
|
||||
}
|
||||
86
internal/timeoff/handler.go
Normal file
86
internal/timeoff/handler.go
Normal 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
140
internal/timeoff/timeoff.go
Normal 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)
|
||||
}
|
||||
121
internal/volunteer/handler.go
Normal file
121
internal/volunteer/handler.go
Normal 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)
|
||||
}
|
||||
127
internal/volunteer/volunteer.go
Normal file
127
internal/volunteer/volunteer.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user