feat: add appointment CRUD backend (Phase 1D)
- AppointmentService with tenant-scoped List, GetByID, Create, Update, Delete
- List supports filtering by case_id, appointment_type, and date range (start_from/start_to)
- AppointmentHandler with JSON request/response handling and input validation
- Router wired up: GET/POST /api/appointments, PUT/DELETE /api/appointments/{id}
This commit is contained in:
216
backend/internal/handlers/appointments.go
Normal file
216
backend/internal/handlers/appointments.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||
)
|
||||
|
||||
type AppointmentHandler struct {
|
||||
svc *services.AppointmentService
|
||||
}
|
||||
|
||||
func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler {
|
||||
return &AppointmentHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
filter := services.AppointmentFilter{}
|
||||
|
||||
if v := r.URL.Query().Get("case_id"); v != "" {
|
||||
id, err := uuid.Parse(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||
return
|
||||
}
|
||||
filter.CaseID = &id
|
||||
}
|
||||
if v := r.URL.Query().Get("type"); v != "" {
|
||||
filter.Type = &v
|
||||
}
|
||||
if v := r.URL.Query().Get("start_from"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_from (use RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.StartFrom = &t
|
||||
}
|
||||
if v := r.URL.Query().Get("start_to"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_to (use RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.StartTo = &t
|
||||
}
|
||||
|
||||
appointments, err := h.svc.List(r.Context(), tenantID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list appointments")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, appointments)
|
||||
}
|
||||
|
||||
type createAppointmentRequest struct {
|
||||
CaseID *uuid.UUID `json:"case_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt *time.Time `json:"end_at"`
|
||||
Location *string `json:"location"`
|
||||
AppointmentType *string `json:"appointment_type"`
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
var req createAppointmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.StartAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||
return
|
||||
}
|
||||
|
||||
appt := &models.Appointment{
|
||||
TenantID: tenantID,
|
||||
CaseID: req.CaseID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
StartAt: req.StartAt,
|
||||
EndAt: req.EndAt,
|
||||
Location: req.Location,
|
||||
AppointmentType: req.AppointmentType,
|
||||
}
|
||||
|
||||
if err := h.svc.Create(r.Context(), appt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create appointment")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, appt)
|
||||
}
|
||||
|
||||
type updateAppointmentRequest struct {
|
||||
CaseID *uuid.UUID `json:"case_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt *time.Time `json:"end_at"`
|
||||
Location *string `json:"location"`
|
||||
AppointmentType *string `json:"appointment_type"`
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid appointment id")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch existing to verify ownership
|
||||
existing, err := h.svc.GetByID(r.Context(), tenantID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "appointment not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAppointmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.StartAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||
return
|
||||
}
|
||||
|
||||
existing.CaseID = req.CaseID
|
||||
existing.Title = req.Title
|
||||
existing.Description = req.Description
|
||||
existing.StartAt = req.StartAt
|
||||
existing.EndAt = req.EndAt
|
||||
existing.Location = req.Location
|
||||
existing.AppointmentType = req.AppointmentType
|
||||
|
||||
if err := h.svc.Update(r.Context(), existing); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update appointment")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid appointment id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), tenantID, id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "appointment not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
Reference in New Issue
Block a user