diff --git a/backend/internal/handlers/appointments.go b/backend/internal/handlers/appointments.go new file mode 100644 index 0000000..49d8e16 --- /dev/null +++ b/backend/internal/handlers/appointments.go @@ -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}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 530bc76..30d6d5d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -5,6 +5,8 @@ import ( "net/http" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "github.com/jmoiron/sqlx" ) @@ -15,13 +17,24 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { // Public routes mux.HandleFunc("GET /health", handleHealth(db)) + // Services + appointmentSvc := services.NewAppointmentService(db) + + // Handlers + apptH := handlers.NewAppointmentHandler(appointmentSvc) + // Authenticated API routes api := http.NewServeMux() api.HandleFunc("GET /api/cases", placeholder("cases")) api.HandleFunc("GET /api/deadlines", placeholder("deadlines")) - api.HandleFunc("GET /api/appointments", placeholder("appointments")) api.HandleFunc("GET /api/documents", placeholder("documents")) + // Appointments CRUD + api.HandleFunc("GET /api/appointments", apptH.List) + api.HandleFunc("POST /api/appointments", apptH.Create) + api.HandleFunc("PUT /api/appointments/{id}", apptH.Update) + api.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) + mux.Handle("/api/", authMW.RequireAuth(api)) return mux diff --git a/backend/internal/services/appointment_service.go b/backend/internal/services/appointment_service.go new file mode 100644 index 0000000..e9a246c --- /dev/null +++ b/backend/internal/services/appointment_service.go @@ -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 +}