diff --git a/backend/internal/handlers/appointments.go b/backend/internal/handlers/appointments.go index 16d1111..d8acaab 100644 --- a/backend/internal/handlers/appointments.go +++ b/backend/internal/handlers/appointments.go @@ -22,6 +22,33 @@ func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler return &AppointmentHandler{svc: svc} } +// Get handles GET /api/appointments/{id} +func (h *AppointmentHandler) Get(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 + } + + appt, 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 + } + + writeJSON(w, http.StatusOK, appt) +} + func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) { tenantID, ok := auth.TenantFromContext(r.Context()) if !ok { diff --git a/backend/internal/handlers/case_events.go b/backend/internal/handlers/case_events.go new file mode 100644 index 0000000..861a134 --- /dev/null +++ b/backend/internal/handlers/case_events.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" + "github.com/jmoiron/sqlx" +) + +type CaseEventHandler struct { + db *sqlx.DB +} + +func NewCaseEventHandler(db *sqlx.DB) *CaseEventHandler { + return &CaseEventHandler{db: db} +} + +// Get handles GET /api/case-events/{id} +func (h *CaseEventHandler) Get(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + eventID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid event ID") + return + } + + var event models.CaseEvent + err = h.db.GetContext(r.Context(), &event, + `SELECT id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at + FROM case_events + WHERE id = $1 AND tenant_id = $2`, eventID, tenantID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "case event not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to fetch case event") + return + } + + writeJSON(w, http.StatusOK, event) +} diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index 4efa20c..5b2b265 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -20,6 +20,33 @@ func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHan return &DeadlineHandlers{deadlines: ds, db: db} } +// Get handles GET /api/deadlines/{deadlineID} +func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + deadlineID, err := parsePathUUID(r, "deadlineID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid deadline ID") + return + } + + deadline, err := h.deadlines.GetByID(tenantID, deadlineID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch deadline") + return + } + if deadline == nil { + writeError(w, http.StatusNotFound, "deadline not found") + return + } + + writeJSON(w, http.StatusOK, deadline) +} + // ListAll handles GET /api/deadlines func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) { tenantID, err := resolveTenant(r, h.db) diff --git a/backend/internal/handlers/notes.go b/backend/internal/handlers/notes.go new file mode 100644 index 0000000..68c9173 --- /dev/null +++ b/backend/internal/handlers/notes.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type NoteHandler struct { + svc *services.NoteService +} + +func NewNoteHandler(svc *services.NoteService) *NoteHandler { + return &NoteHandler{svc: svc} +} + +// List handles GET /api/notes?{parent_type}_id={id} +func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + parentType, parentID, err := parseNoteParent(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + notes, err := h.svc.ListByParent(r.Context(), tenantID, parentType, parentID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list notes") + return + } + + writeJSON(w, http.StatusOK, notes) +} + +// Create handles POST /api/notes +func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + userID, _ := auth.UserFromContext(r.Context()) + + var input services.CreateNoteInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if input.Content == "" { + writeError(w, http.StatusBadRequest, "content is required") + return + } + + var createdBy *uuid.UUID + if userID != uuid.Nil { + createdBy = &userID + } + + note, err := h.svc.Create(r.Context(), tenantID, createdBy, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create note") + return + } + + writeJSON(w, http.StatusCreated, note) +} + +// Update handles PUT /api/notes/{id} +func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + noteID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid note ID") + return + } + + var req struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Content == "" { + writeError(w, http.StatusBadRequest, "content is required") + return + } + + note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update note") + return + } + if note == nil { + writeError(w, http.StatusNotFound, "note not found") + return + } + + writeJSON(w, http.StatusOK, note) +} + +// Delete handles DELETE /api/notes/{id} +func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + noteID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid note ID") + return + } + + if err := h.svc.Delete(r.Context(), tenantID, noteID); err != nil { + writeError(w, http.StatusNotFound, "note not found") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// parseNoteParent extracts the parent type and ID from query parameters. +func parseNoteParent(r *http.Request) (string, uuid.UUID, error) { + params := map[string]string{ + "case_id": "case", + "deadline_id": "deadline", + "appointment_id": "appointment", + "case_event_id": "case_event", + } + + for param, parentType := range params { + if v := r.URL.Query().Get(param); v != "" { + id, err := uuid.Parse(v) + if err != nil { + return "", uuid.Nil, fmt.Errorf("invalid %s", param) + } + return parentType, id, nil + } + } + + return "", uuid.Nil, fmt.Errorf("one of case_id, deadline_id, appointment_id, or case_event_id is required") +} diff --git a/backend/internal/models/note.go b/backend/internal/models/note.go new file mode 100644 index 0000000..74496aa --- /dev/null +++ b/backend/internal/models/note.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Note struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"` + DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"` + AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"` + CaseEventID *uuid.UUID `db:"case_event_id" json:"case_event_id,omitempty"` + Content string `db:"content" json:"content"` + CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 16a550b..3c6f8a5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -40,6 +40,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) + noteSvc := services.NewNoteService(db) dashboardSvc := services.NewDashboardService(db) // Handlers @@ -51,6 +52,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) dashboardH := handlers.NewDashboardHandler(dashboardSvc) + noteH := handlers.NewNoteHandler(noteSvc) + eventH := handlers.NewCaseEventHandler(db) docH := handlers.NewDocumentHandler(documentSvc) // Public routes @@ -85,6 +88,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete) // Deadlines + scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get) scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll) scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase) scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create) @@ -101,11 +105,21 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) // Appointments + scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) 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) + // Case events + scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get) + + // Notes + scoped.HandleFunc("GET /api/notes", noteH.List) + scoped.HandleFunc("POST /api/notes", noteH.Create) + scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update) + scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete) + // Dashboard scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go index a8c1b07..bc45194 100644 --- a/backend/internal/services/dashboard_service.go +++ b/backend/internal/services/dashboard_service.go @@ -42,6 +42,7 @@ type UpcomingDeadline struct { ID uuid.UUID `json:"id" db:"id"` Title string `json:"title" db:"title"` DueDate string `json:"due_date" db:"due_date"` + CaseID uuid.UUID `json:"case_id" db:"case_id"` CaseNumber string `json:"case_number" db:"case_number"` CaseTitle string `json:"case_title" db:"case_title"` Status string `json:"status" db:"status"` @@ -56,8 +57,10 @@ type UpcomingAppointment struct { } type RecentActivity struct { + ID uuid.UUID `json:"id" db:"id"` EventType *string `json:"event_type" db:"event_type"` Title string `json:"title" db:"title"` + CaseID uuid.UUID `json:"case_id" db:"case_id"` CaseNumber string `json:"case_number" db:"case_number"` EventDate *time.Time `json:"event_date" db:"event_date"` } @@ -109,7 +112,7 @@ func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*Dashbo // Upcoming deadlines (next 7 days) deadlineQuery := ` - SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status + SELECT d.id, d.title, d.due_date, d.case_id, c.case_number, c.title AS case_title, d.status FROM deadlines d JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3 @@ -135,7 +138,7 @@ func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*Dashbo // Recent activity (last 10 case events) activityQuery := ` - SELECT ce.event_type, ce.title, c.case_number, ce.event_date + SELECT ce.id, ce.event_type, ce.title, ce.case_id, c.case_number, ce.event_date FROM case_events ce JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id WHERE ce.tenant_id = $1 diff --git a/backend/internal/services/note_service.go b/backend/internal/services/note_service.go new file mode 100644 index 0000000..d990ce4 --- /dev/null +++ b/backend/internal/services/note_service.go @@ -0,0 +1,120 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type NoteService struct { + db *sqlx.DB +} + +func NewNoteService(db *sqlx.DB) *NoteService { + return &NoteService{db: db} +} + +// ListByParent returns all notes for a given parent entity, scoped to tenant. +func (s *NoteService) ListByParent(ctx context.Context, tenantID uuid.UUID, parentType string, parentID uuid.UUID) ([]models.Note, error) { + col, err := parentColumn(parentType) + if err != nil { + return nil, err + } + + query := fmt.Sprintf( + `SELECT id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, + content, created_by, created_at, updated_at + FROM notes + WHERE tenant_id = $1 AND %s = $2 + ORDER BY created_at DESC`, col) + + var notes []models.Note + if err := s.db.SelectContext(ctx, ¬es, query, tenantID, parentID); err != nil { + return nil, fmt.Errorf("listing notes by %s: %w", parentType, err) + } + if notes == nil { + notes = []models.Note{} + } + return notes, nil +} + +type CreateNoteInput struct { + CaseID *uuid.UUID `json:"case_id,omitempty"` + DeadlineID *uuid.UUID `json:"deadline_id,omitempty"` + AppointmentID *uuid.UUID `json:"appointment_id,omitempty"` + CaseEventID *uuid.UUID `json:"case_event_id,omitempty"` + Content string `json:"content"` +} + +// Create inserts a new note. +func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy *uuid.UUID, input CreateNoteInput) (*models.Note, error) { + id := uuid.New() + now := time.Now().UTC() + + query := `INSERT INTO notes (id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at` + + var n models.Note + err := s.db.GetContext(ctx, &n, query, + id, tenantID, input.CaseID, input.DeadlineID, input.AppointmentID, input.CaseEventID, + input.Content, createdBy, now) + if err != nil { + return nil, fmt.Errorf("creating note: %w", err) + } + return &n, nil +} + +// Update modifies a note's content. +func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, content string) (*models.Note, error) { + query := `UPDATE notes SET content = $1, updated_at = $2 + WHERE id = $3 AND tenant_id = $4 + RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at` + + var n models.Note + err := s.db.GetContext(ctx, &n, query, content, time.Now().UTC(), noteID, tenantID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("updating note: %w", err) + } + return &n, nil +} + +// Delete removes a note. +func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) error { + result, err := s.db.ExecContext(ctx, "DELETE FROM notes WHERE id = $1 AND tenant_id = $2", noteID, tenantID) + if err != nil { + return fmt.Errorf("deleting note: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking delete result: %w", err) + } + if rows == 0 { + return fmt.Errorf("note not found") + } + return nil +} + +func parentColumn(parentType string) (string, error) { + switch parentType { + case "case": + return "case_id", nil + case "deadline": + return "deadline_id", nil + case "appointment": + return "appointment_id", nil + case "case_event": + return "case_event_id", nil + default: + return "", fmt.Errorf("invalid parent type: %s", parentType) + } +}