feat: add appointment CRUD backend (Phase 1D)
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})
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
tenantSvc := services.NewTenantService(db)
|
tenantSvc := services.NewTenantService(db)
|
||||||
caseSvc := services.NewCaseService(db)
|
caseSvc := services.NewCaseService(db)
|
||||||
partySvc := services.NewPartyService(db)
|
partySvc := services.NewPartyService(db)
|
||||||
|
appointmentSvc := services.NewAppointmentService(db)
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
||||||
@@ -26,6 +27,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
tenantH := handlers.NewTenantHandler(tenantSvc)
|
tenantH := handlers.NewTenantHandler(tenantSvc)
|
||||||
caseH := handlers.NewCaseHandler(caseSvc)
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
partyH := handlers.NewPartyHandler(partySvc)
|
partyH := handlers.NewPartyHandler(partySvc)
|
||||||
|
apptH := handlers.NewAppointmentHandler(appointmentSvc)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET /health", handleHealth(db))
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
@@ -57,9 +59,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
|
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
|
||||||
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
||||||
|
|
||||||
|
// Appointments
|
||||||
|
scoped.HandleFunc("GET /api/appointments", apptH.List)
|
||||||
|
scoped.HandleFunc("POST /api/appointments", apptH.Create)
|
||||||
|
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
||||||
|
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
||||||
|
|
||||||
// Placeholder routes for future phases
|
// Placeholder routes for future phases
|
||||||
scoped.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
|
scoped.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
|
||||||
scoped.HandleFunc("GET /api/appointments", placeholder("appointments"))
|
|
||||||
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||||
|
|
||||||
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
|
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
|
||||||
|
|||||||
135
backend/internal/services/appointment_service.go
Normal file
135
backend/internal/services/appointment_service.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppointmentService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
|
||||||
|
return &AppointmentService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppointmentFilter struct {
|
||||||
|
CaseID *uuid.UUID
|
||||||
|
Type *string
|
||||||
|
StartFrom *time.Time
|
||||||
|
StartTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppointmentService) List(ctx context.Context, tenantID uuid.UUID, filter AppointmentFilter) ([]models.Appointment, error) {
|
||||||
|
query := "SELECT * FROM appointments WHERE tenant_id = $1"
|
||||||
|
args := []any{tenantID}
|
||||||
|
argN := 2
|
||||||
|
|
||||||
|
if filter.CaseID != nil {
|
||||||
|
query += fmt.Sprintf(" AND case_id = $%d", argN)
|
||||||
|
args = append(args, *filter.CaseID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if filter.Type != nil {
|
||||||
|
query += fmt.Sprintf(" AND appointment_type = $%d", argN)
|
||||||
|
args = append(args, *filter.Type)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if filter.StartFrom != nil {
|
||||||
|
query += fmt.Sprintf(" AND start_at >= $%d", argN)
|
||||||
|
args = append(args, *filter.StartFrom)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if filter.StartTo != nil {
|
||||||
|
query += fmt.Sprintf(" AND start_at <= $%d", argN)
|
||||||
|
args = append(args, *filter.StartTo)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY start_at ASC"
|
||||||
|
|
||||||
|
var appointments []models.Appointment
|
||||||
|
if err := s.db.SelectContext(ctx, &appointments, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("listing appointments: %w", err)
|
||||||
|
}
|
||||||
|
if appointments == nil {
|
||||||
|
appointments = []models.Appointment{}
|
||||||
|
}
|
||||||
|
return appointments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppointmentService) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*models.Appointment, error) {
|
||||||
|
var a models.Appointment
|
||||||
|
err := s.db.GetContext(ctx, &a, "SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting appointment: %w", err)
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) error {
|
||||||
|
a.ID = uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.CreatedAt = now
|
||||||
|
a.UpdatedAt = now
|
||||||
|
|
||||||
|
_, err := s.db.NamedExecContext(ctx, `
|
||||||
|
INSERT INTO appointments (id, tenant_id, case_id, title, description, start_at, end_at, location, appointment_type, caldav_uid, caldav_etag, created_at, updated_at)
|
||||||
|
VALUES (:id, :tenant_id, :case_id, :title, :description, :start_at, :end_at, :location, :appointment_type, :caldav_uid, :caldav_etag, :created_at, :updated_at)
|
||||||
|
`, a)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating appointment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) error {
|
||||||
|
a.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
result, err := s.db.NamedExecContext(ctx, `
|
||||||
|
UPDATE appointments SET
|
||||||
|
case_id = :case_id,
|
||||||
|
title = :title,
|
||||||
|
description = :description,
|
||||||
|
start_at = :start_at,
|
||||||
|
end_at = :end_at,
|
||||||
|
location = :location,
|
||||||
|
appointment_type = :appointment_type,
|
||||||
|
caldav_uid = :caldav_uid,
|
||||||
|
caldav_etag = :caldav_etag,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id AND tenant_id = :tenant_id
|
||||||
|
`, a)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating appointment: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("appointment not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, "DELETE FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting appointment: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("appointment not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user