Compare commits

...

2 Commits

Author SHA1 Message Date
m
1e88dffd82 feat: Phase A backend — notes CRUD, detail endpoints, dashboard fix
- Create kanzlai.notes table (polymorphic FK with CHECK constraint,
  partial indexes, RLS)
- Add Note model, NoteService (ListByParent, Create, Update, Delete),
  and NoteHandler with endpoints: GET/POST /api/notes, PUT/DELETE /api/notes/{id}
- Add GET /api/deadlines/{deadlineID} detail endpoint
- Add GET /api/appointments/{id} detail endpoint
- Add GET /api/case-events/{id} detail endpoint (new CaseEventHandler)
- Fix dashboard query: add case_id to upcoming_deadlines SELECT,
  add id and case_id to recent_activity SELECT
- Register all new routes in router.go
2026-03-25 19:26:21 +01:00
m
9ad58e1ba3 docs: design document for dashboard redesign + detail pages 2026-03-25 18:51:44 +01:00
8 changed files with 424 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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, &notes, 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)
}
}