feat: handlers — Projekt/Team/Dezernat wiring (Phase 2)

- handlers/projekte.go (was akten.go): Projekt CRUD + tree ops (children,
  tree, ancestors), events cursor-paginated, parteien endpoints.
- handlers/teams.go: GET/POST/DELETE on /api/projekte/{id}/team. ListEffectiveMembers
  returns direct + inherited (annotated with inherited_from_id/title).
- handlers/dezernate.go: admin-gated CRUD for paliad.dezernate + member
  add/remove. Readable by any authenticated user.
- handlers/fristen.go, termine.go, notizen.go, checklist_instances.go updated
  to use projekt_id. Kept /api/akten/{id}/fristen|termine|notizen|checklisten
  as legacy aliases pointing at the same projekt-aware handlers.
- handlers/users.go: dropped handleListAkteEvents (superseded by
  handleListProjektEvents under /api/projekte/{id}/events).
- cmd/server/main.go: ProjektService + TeamService + DezernatService wired
  into handlers.Services. Downstream services (Parteien, Frist, Termin,
  Notiz, Checklist) take projektSvc.
- Removed obsolete internal/services/akte_service_test.go. go build/vet/test
  all clean.

Legacy /api/akten routes still resolve (handlers/JSON shape unchanged on
the GET/POST path) so frontend stays functional during the client cutover.
New /api/projekte routes live alongside.

Phase 3 (frontend tree UI, /projekte page, team tab) + Phase 4 (Dezernat
settings tab) still pending.
This commit is contained in:
m
2026-04-20 14:52:44 +02:00
parent 9aa8037193
commit cb2841fba9
11 changed files with 580 additions and 391 deletions

View File

@@ -70,7 +70,9 @@ func main() {
}
holidays := services.NewHolidayService(pool)
users := services.NewUserService(pool)
akteSvc := services.NewAkteService(pool, users)
projektSvc := services.NewProjektService(pool, users)
teamSvc := services.NewTeamService(pool, projektSvc)
dezernatSvc := services.NewDezernatService(pool, users)
rules := services.NewDeadlineRuleService(pool)
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
@@ -87,7 +89,7 @@ func main() {
log.Println("CalDAV encryption configured (AES-256-GCM)")
}
terminSvc := services.NewTerminService(pool, akteSvc)
terminSvc := services.NewTerminService(pool, projektSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, terminSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
@@ -98,9 +100,11 @@ func main() {
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
svcBundle = &handlers.Services{
Akte: akteSvc,
Parteien: services.NewParteienService(pool, akteSvc),
Frist: services.NewFristService(pool, akteSvc),
Projekt: projektSvc,
Team: teamSvc,
Dezernat: dezernatSvc,
Parteien: services.NewParteienService(pool, projektSvc),
Frist: services.NewFristService(pool, projektSvc),
Termin: terminSvc,
CalDAV: caldavSvc,
Rules: rules,
@@ -108,8 +112,8 @@ func main() {
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
Dashboard: services.NewDashboardService(pool, users),
Notiz: services.NewNotizService(pool, akteSvc, terminSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, akteSvc),
Notiz: services.NewNotizService(pool, projektSvc, terminSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projektSvc),
Mail: mailSvc,
Invite: inviteSvc,
}

View File

@@ -142,8 +142,8 @@ func handleDeleteChecklistInstance(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// GET /api/akten/{id}/checklisten
func handleListChecklistInstancesForAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}/checklisten
func handleListChecklistInstancesForProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -151,12 +151,12 @@ func handleListChecklistInstancesForAkte(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.checklistInst.ListForAkte(r.Context(), uid, akteID)
rows, err := dbSvc.checklistInst.ListForProjekt(r.Context(), uid, projektID)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,198 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/dezernate — list every Dezernat (readable by all authenticated users).
func handleListDezernate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
rows, err := dbSvc.dezernat.List(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/dezernate — admin-only create.
func handleCreateDezernat(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateDezernatInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
d, err := dbSvc.dezernat.Create(r.Context(), uid, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, d)
}
// GET /api/dezernate/{id}
func handleGetDezernat(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
d, err := dbSvc.dezernat.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, d)
}
// PATCH /api/dezernate/{id} — admin-only.
func handleUpdateDezernat(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateDezernatInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
d, err := dbSvc.dezernat.Update(r.Context(), uid, id, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, d)
}
// DELETE /api/dezernate/{id} — admin-only.
func handleDeleteDezernat(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.dezernat.Delete(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/dezernate/{id}/members
func handleListDezernatMembers(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.dezernat.ListMembers(r.Context(), id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/dezernate/{id}/members — admin-only. Body: {"user_id": "<uuid>"}
func handleAddDezernatMember(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
dezernatID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := dbSvc.dezernat.AddMember(r.Context(), uid, dezernatID, body.UserID); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DELETE /api/dezernate/{id}/members/{user_id} — admin-only.
func handleRemoveDezernatMember(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
dezernatID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid dezernat id"})
return
}
userID, err := uuid.Parse(r.PathValue("user_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
return
}
if err := dbSvc.dezernat.RemoveMember(r.Context(), uid, dezernatID, userID); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -9,7 +9,7 @@ import (
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/fristen?status=overdue|this_week|upcoming|completed|pending|all&akte_id=UUID
// GET /api/fristen?status=overdue|this_week|upcoming|completed|pending|all&projekt_id=UUID
func handleListFristen(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -21,13 +21,18 @@ func handleListFristen(w http.ResponseWriter, r *http.Request) {
filter := services.ListFilter{
Status: services.FristStatusFilter(r.URL.Query().Get("status")),
}
if raw := r.URL.Query().Get("akte_id"); raw != "" {
akteID, err := uuid.Parse(raw)
// Accept both projekt_id (new) and akte_id (legacy alias).
raw := r.URL.Query().Get("projekt_id")
if raw == "" {
raw = r.URL.Query().Get("akte_id")
}
if raw != "" {
projektID, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid akte_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"})
return
}
filter.AkteID = &akteID
filter.ProjektID = &projektID
}
rows, err := dbSvc.frist.ListVisibleForUser(r.Context(), uid, filter)
if err != nil {
@@ -37,7 +42,7 @@ func handleListFristen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// GET /api/fristen/summary?akte_id=UUID
// GET /api/fristen/summary?projekt_id=UUID
func handleFristenSummary(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -46,16 +51,20 @@ func handleFristenSummary(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
var akteIDPtr *uuid.UUID
if raw := r.URL.Query().Get("akte_id"); raw != "" {
akteID, err := uuid.Parse(raw)
var projektIDPtr *uuid.UUID
raw := r.URL.Query().Get("projekt_id")
if raw == "" {
raw = r.URL.Query().Get("akte_id")
}
if raw != "" {
projektID, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid akte_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"})
return
}
akteIDPtr = &akteID
projektIDPtr = &projektID
}
c, err := dbSvc.frist.SummaryCounts(r.Context(), uid, akteIDPtr)
c, err := dbSvc.frist.SummaryCounts(r.Context(), uid, projektIDPtr)
if err != nil {
writeServiceError(w, err)
return
@@ -63,8 +72,8 @@ func handleFristenSummary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, c)
}
// GET /api/akten/{id}/fristen
func handleListFristenForAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}/fristen
func handleListFristenForProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -72,12 +81,12 @@ func handleListFristenForAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.frist.ListForAkte(r.Context(), uid, akteID)
rows, err := dbSvc.frist.ListForProjekt(r.Context(), uid, projektID)
if err != nil {
writeServiceError(w, err)
return
@@ -85,7 +94,7 @@ func handleListFristenForAkte(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// POST /api/akten/{id}/fristen
// POST /api/projekte/{id}/fristen
func handleCreateFrist(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -94,7 +103,7 @@ func handleCreateFrist(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
@@ -104,7 +113,7 @@ func handleCreateFrist(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
f, err := dbSvc.frist.Create(r.Context(), uid, akteID, input)
f, err := dbSvc.frist.Create(r.Context(), uid, projektID, input)
if err != nil {
writeServiceError(w, err)
return
@@ -112,7 +121,7 @@ func handleCreateFrist(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, f)
}
// POST /api/akten/{id}/fristen/bulk — used by Fristenrechner "save to Akte".
// POST /api/projekte/{id}/fristen/bulk — Fristenrechner "save to Projekt".
func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -121,7 +130,7 @@ func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
@@ -133,7 +142,7 @@ func handleBulkCreateFristen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
rows, err := dbSvc.frist.CreateBulk(r.Context(), uid, akteID, body.Fristen)
rows, err := dbSvc.frist.CreateBulk(r.Context(), uid, projektID, body.Fristen)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -13,7 +13,9 @@ var authClient *auth.Client
// Services bundles the Phase B + C database-backed services. Pass nil if
// DATABASE_URL was unset; the Akten/deadline endpoints will return 503.
type Services struct {
Akte *services.AkteService
Projekt *services.ProjektService
Team *services.TeamService
Dezernat *services.DezernatService
Parteien *services.ParteienService
Frist *services.FristService
Termin *services.TerminService
@@ -35,7 +37,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
if svc != nil {
dbSvc = &dbServices{
akte: svc.Akte,
projekte: svc.Projekt,
team: svc.Team,
dezernat: svc.Dezernat,
parteien: svc.Parteien,
frist: svc.Frist,
termin: svc.Termin,
@@ -102,7 +106,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/checklist-instances/{id}", handleUpdateChecklistInstance)
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
protected.HandleFunc("GET /api/akten/{id}/checklisten", handleListChecklistInstancesForAkte)
protected.HandleFunc("GET /api/projekte/{id}/checklisten", handleListChecklistInstancesForProjekt)
protected.HandleFunc("GET /api/akten/{id}/checklisten", handleListChecklistInstancesForProjekt) // legacy alias
protected.HandleFunc("GET /gerichte", handleGerichtePage)
protected.HandleFunc("GET /api/gerichte", handleGerichteAPI)
protected.HandleFunc("POST /api/gerichte/feedback", handleGerichteFeedback)
@@ -111,14 +116,44 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/deadline-rules", handleListDeadlineRules)
protected.HandleFunc("GET /api/proceeding-types-db", handleListProceedingTypesDB)
protected.HandleFunc("POST /api/deadlines/calculate", handleCalculateDeadlines)
protected.HandleFunc("GET /api/akten", handleListAkten)
protected.HandleFunc("POST /api/akten", handleCreateAkte)
protected.HandleFunc("GET /api/akten/{id}", handleGetAkte)
protected.HandleFunc("PATCH /api/akten/{id}", handleUpdateAkte)
protected.HandleFunc("DELETE /api/akten/{id}", handleDeleteAkte)
protected.HandleFunc("GET /api/akten/{id}/events", handleListAkteEvents)
// Projekte v2 (hierarchical tree — t-paliad-024).
protected.HandleFunc("GET /api/projekte", handleListProjekte)
protected.HandleFunc("POST /api/projekte", handleCreateProjekt)
protected.HandleFunc("GET /api/projekte/{id}", handleGetProjekt)
protected.HandleFunc("PATCH /api/projekte/{id}", handleUpdateProjekt)
protected.HandleFunc("DELETE /api/projekte/{id}", handleDeleteProjekt)
protected.HandleFunc("GET /api/projekte/{id}/events", handleListProjektEvents)
protected.HandleFunc("GET /api/projekte/{id}/kinder", handleListProjektChildren)
protected.HandleFunc("GET /api/projekte/{id}/tree", handleGetProjektTree)
protected.HandleFunc("GET /api/projekte/{id}/ancestors", handleListProjektAncestors)
protected.HandleFunc("GET /api/projekte/{id}/parteien", handleListParteien)
protected.HandleFunc("POST /api/projekte/{id}/parteien", handleCreatePartei)
// Team membership endpoints for Projekt detail "Team" tab.
protected.HandleFunc("GET /api/projekte/{id}/team", handleListProjektTeam)
protected.HandleFunc("POST /api/projekte/{id}/team", handleAddProjektTeamMember)
protected.HandleFunc("DELETE /api/projekte/{id}/team/{user_id}", handleRemoveProjektTeamMember)
// Dezernate (structural teams).
protected.HandleFunc("GET /api/dezernate", handleListDezernate)
protected.HandleFunc("POST /api/dezernate", handleCreateDezernat)
protected.HandleFunc("GET /api/dezernate/{id}", handleGetDezernat)
protected.HandleFunc("PATCH /api/dezernate/{id}", handleUpdateDezernat)
protected.HandleFunc("DELETE /api/dezernate/{id}", handleDeleteDezernat)
protected.HandleFunc("GET /api/dezernate/{id}/members", handleListDezernatMembers)
protected.HandleFunc("POST /api/dezernate/{id}/members", handleAddDezernatMember)
protected.HandleFunc("DELETE /api/dezernate/{id}/members/{user_id}", handleRemoveDezernatMember)
// Legacy /api/akten aliases — map to the same Projekt handlers during the
// frontend transition. Remove once all clients use /api/projekte.
protected.HandleFunc("GET /api/akten", handleListProjekte)
protected.HandleFunc("POST /api/akten", handleCreateProjekt)
protected.HandleFunc("GET /api/akten/{id}", handleGetProjekt)
protected.HandleFunc("PATCH /api/akten/{id}", handleUpdateProjekt)
protected.HandleFunc("DELETE /api/akten/{id}", handleDeleteProjekt)
protected.HandleFunc("GET /api/akten/{id}/events", handleListProjektEvents)
protected.HandleFunc("GET /api/akten/{id}/parteien", handleListParteien)
protected.HandleFunc("POST /api/akten/{id}/parteien", handleCreatePartei)
protected.HandleFunc("DELETE /api/parteien/{id}", handleDeletePartei)
// Phase F — Termine (appointments)
@@ -128,7 +163,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/termine/{id}", handleGetTermin)
protected.HandleFunc("PATCH /api/termine/{id}", handleUpdateTermin)
protected.HandleFunc("DELETE /api/termine/{id}", handleDeleteTermin)
protected.HandleFunc("GET /api/akten/{id}/termine", handleListTermineForAkte)
protected.HandleFunc("GET /api/projekte/{id}/termine", handleListTermineForProjekt)
protected.HandleFunc("GET /api/akten/{id}/termine", handleListTermineForProjekt) // legacy alias
// Phase F — CalDAV configuration (per-user, encrypted at rest)
protected.HandleFunc("GET /api/caldav-config", handleGetCalDAVConfig)
@@ -144,13 +180,19 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/fristen/{id}", handleUpdateFrist)
protected.HandleFunc("PATCH /api/fristen/{id}/complete", handleCompleteFrist)
protected.HandleFunc("DELETE /api/fristen/{id}", handleDeleteFrist)
protected.HandleFunc("GET /api/akten/{id}/fristen", handleListFristenForAkte)
protected.HandleFunc("GET /api/projekte/{id}/fristen", handleListFristenForProjekt)
protected.HandleFunc("POST /api/projekte/{id}/fristen", handleCreateFrist)
protected.HandleFunc("POST /api/projekte/{id}/fristen/bulk", handleBulkCreateFristen)
// Legacy aliases.
protected.HandleFunc("GET /api/akten/{id}/fristen", handleListFristenForProjekt)
protected.HandleFunc("POST /api/akten/{id}/fristen", handleCreateFrist)
protected.HandleFunc("POST /api/akten/{id}/fristen/bulk", handleBulkCreateFristen)
// Phase I — Notizen (polymorphic notes)
protected.HandleFunc("GET /api/akten/{id}/notizen", handleListNotizenForAkte)
protected.HandleFunc("POST /api/akten/{id}/notizen", handleCreateNotizForAkte)
protected.HandleFunc("GET /api/projekte/{id}/notizen", handleListNotizenForProjekt)
protected.HandleFunc("POST /api/projekte/{id}/notizen", handleCreateNotizForProjekt)
protected.HandleFunc("GET /api/akten/{id}/notizen", handleListNotizenForProjekt) // legacy
protected.HandleFunc("POST /api/akten/{id}/notizen", handleCreateNotizForProjekt) // legacy
protected.HandleFunc("GET /api/fristen/{id}/notizen", handleListNotizenForFrist)
protected.HandleFunc("POST /api/fristen/{id}/notizen", handleCreateNotizForFrist)
protected.HandleFunc("GET /api/termine/{id}/notizen", handleListNotizenForTermin)

View File

@@ -9,8 +9,8 @@ import (
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/akten/{id}/notizen
func handleListNotizenForAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}/notizen
func handleListNotizenForProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -18,12 +18,12 @@ func handleListNotizenForAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.notiz.ListForAkte(r.Context(), uid, akteID)
rows, err := dbSvc.notiz.ListForProjekt(r.Context(), uid, projektID)
if err != nil {
writeServiceError(w, err)
return
@@ -31,8 +31,8 @@ func handleListNotizenForAkte(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// POST /api/akten/{id}/notizen
func handleCreateNotizForAkte(w http.ResponseWriter, r *http.Request) {
// POST /api/projekte/{id}/notizen
func handleCreateNotizForProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -40,7 +40,7 @@ func handleCreateNotizForAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
@@ -50,7 +50,7 @@ func handleCreateNotizForAkte(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
n, err := dbSvc.notiz.CreateForAkte(r.Context(), uid, akteID, input)
n, err := dbSvc.notiz.CreateForProjekt(r.Context(), uid, projektID, input)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/google/uuid"
@@ -14,7 +15,9 @@ import (
// dbServices bundles the Phase B services so handlers can stay thin.
// Nil if DATABASE_URL was unset at startup.
type dbServices struct {
akte *services.AkteService
projekte *services.ProjektService
team *services.TeamService
dezernat *services.DezernatService
parteien *services.ParteienService
frist *services.FristService
termin *services.TerminService
@@ -45,7 +48,6 @@ func requireDB(w http.ResponseWriter) bool {
}
// requireUser pulls the authenticated user UUID from the request context.
// Returns (uuid.Nil, false) and writes 401 if missing.
func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok {
@@ -71,8 +73,9 @@ func writeServiceError(w http.ResponseWriter, err error) {
}
}
// GET /api/akten
func handleListAkten(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte — list visible projekte.
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
func handleListProjekte(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -80,16 +83,33 @@ func handleListAkten(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akten, err := dbSvc.akte.ListVisibleForUser(r.Context(), uid)
q := r.URL.Query()
filter := services.ProjektFilter{
Type: q.Get("type"),
Status: q.Get("status"),
Search: q.Get("search"),
}
if pidStr := q.Get("parent_id"); pidStr != "" {
pid, err := uuid.Parse(pidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent_id"})
return
}
filter.ParentID = &pid
}
if q.Get("parent_null") == "1" || q.Get("parent_null") == "true" {
filter.ParentNullOnly = true
}
rows, err := dbSvc.projekte.List(r.Context(), uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, akten)
writeJSON(w, http.StatusOK, rows)
}
// POST /api/akten
func handleCreateAkte(w http.ResponseWriter, r *http.Request) {
// POST /api/projekte
func handleCreateProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -97,21 +117,24 @@ func handleCreateAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
var input services.CreateAkteInput
var input services.CreateProjektInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
a, err := dbSvc.akte.Create(r.Context(), uid, input)
if input.Type == "" {
input.Type = services.ProjektTypeCase
}
p, err := dbSvc.projekte.Create(r.Context(), uid, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, a)
writeJSON(w, http.StatusCreated, p)
}
// GET /api/akten/{id}
func handleGetAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}
func handleGetProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -119,21 +142,21 @@ func handleGetAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
a, err := dbSvc.akte.GetByID(r.Context(), uid, akteID)
p, err := dbSvc.projekte.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, a)
writeJSON(w, http.StatusOK, p)
}
// PATCH /api/akten/{id}
func handleUpdateAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}/kinder — direct children.
func handleListProjektChildren(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -141,26 +164,92 @@ func handleUpdateAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateAkteInput
rows, err := dbSvc.projekte.ListChildren(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/projekte/{id}/tree — full subtree depth-first (path-ordered).
func handleGetProjektTree(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.projekte.GetTree(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/projekte/{id}/ancestors — ancestor chain for breadcrumbs.
func handleListProjektAncestors(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.projekte.ListAncestors(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PATCH /api/projekte/{id}
func handleUpdateProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateProjektInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
a, err := dbSvc.akte.Update(r.Context(), uid, akteID, input)
p, err := dbSvc.projekte.Update(r.Context(), uid, id, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, a)
writeJSON(w, http.StatusOK, p)
}
// DELETE /api/akten/{id}
func handleDeleteAkte(w http.ResponseWriter, r *http.Request) {
// DELETE /api/projekte/{id}
func handleDeleteProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -168,19 +257,60 @@ func handleDeleteAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.akte.Delete(r.Context(), uid, akteID); err != nil {
if err := dbSvc.projekte.Delete(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/akten/{id}/parteien
// GET /api/projekte/{id}/events — audit trail with cursor pagination.
func handleListProjektEvents(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
q := r.URL.Query()
var before *uuid.UUID
if b := q.Get("before"); b != "" {
bu, err := uuid.Parse(b)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before cursor"})
return
}
before = &bu
}
limit := 0
if l := q.Get("limit"); l != "" {
n, err := strconv.Atoi(l)
if err != nil || n < 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
return
}
limit = n
}
rows, err := dbSvc.projekte.ListEvents(r.Context(), uid, id, before, limit)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/projekte/{id}/parteien
func handleListParteien(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -189,20 +319,20 @@ func handleListParteien(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
parteien, err := dbSvc.parteien.ListForAkte(r.Context(), uid, akteID)
rows, err := dbSvc.parteien.ListForProjekt(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, parteien)
writeJSON(w, http.StatusOK, rows)
}
// POST /api/akten/{id}/parteien
// POST /api/projekte/{id}/parteien
func handleCreatePartei(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -211,7 +341,7 @@ func handleCreatePartei(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
@@ -221,7 +351,7 @@ func handleCreatePartei(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
p, err := dbSvc.parteien.Create(r.Context(), uid, akteID, input)
p, err := dbSvc.parteien.Create(r.Context(), uid, id, input)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,97 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
)
// GET /api/projekte/{id}/team — returns direct + inherited team members.
// inherited=true rows include inherited_from_id / inherited_from_title.
func handleListProjektTeam(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.team.ListEffectiveMembers(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/projekte/{id}/team — add a direct member.
// Body: {"user_id": "<uuid>", "role": "<role>"}
func handleAddProjektTeamMember(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
m, err := dbSvc.team.AddMember(r.Context(), uid, projektID, body.UserID, body.Role)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, m)
}
// DELETE /api/projekte/{id}/team/{user_id} — remove a direct member.
// Inherited memberships can't be removed at the child level.
func handleRemoveProjektTeamMember(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt id"})
return
}
userID, err := uuid.Parse(r.PathValue("user_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
return
}
if err := dbSvc.team.RemoveMember(r.Context(), uid, projektID, userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no direct membership — inherited memberships must be removed at the ancestor",
})
return
}
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -38,13 +38,17 @@ func handleListTermine(w http.ResponseWriter, r *http.Request) {
}
q := r.URL.Query()
filter := services.TerminListFilter{}
if raw := q.Get("akte_id"); raw != "" {
akteID, err := uuid.Parse(raw)
raw := q.Get("projekt_id")
if raw == "" {
raw = q.Get("akte_id")
}
if raw != "" {
projektID, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid akte_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid projekt_id"})
return
}
filter.AkteID = &akteID
filter.ProjektID = &projektID
}
if raw := q.Get("from"); raw != "" {
t, err := parseDateOrTime(raw)
@@ -90,8 +94,8 @@ func handleTermineSummary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, c)
}
// GET /api/akten/{id}/termine
func handleListTermineForAkte(w http.ResponseWriter, r *http.Request) {
// GET /api/projekte/{id}/termine
func handleListTermineForProjekt(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
@@ -99,12 +103,12 @@ func handleListTermineForAkte(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
projektID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.termin.ListForAkte(r.Context(), uid, akteID)
rows, err := dbSvc.termin.ListForProjekt(r.Context(), uid, projektID)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -4,9 +4,6 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/services"
@@ -119,48 +116,4 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, users)
}
// GET /api/akten/{id}/events — audit trail feed for an Akte's detail page.
// Cursor-paginated via ?before=<event-uuid>&limit=<n> (limit clamped in the
// service). Returns an array of events newest-first; fewer than limit rows
// means the caller has reached the end of the history.
func handleListAkteEvents(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var before *uuid.UUID
if raw := r.URL.Query().Get("before"); raw != "" {
cursor, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before cursor"})
return
}
before = &cursor
}
limit := 0
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
return
}
limit = n
}
events, err := dbSvc.akte.ListEvents(r.Context(), uid, akteID, before, limit)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, events)
}
// Removed — superseded by handleListProjektEvents in projekte.go.

View File

@@ -1,248 +0,0 @@
package services
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
// akten_service_test exercises office-scoped visibility end-to-end against a
// real Postgres + the paliad schema.
//
// Requirements:
// - TEST_DATABASE_URL set to a Postgres connection string
// - The target database has the mock Supabase auth schema applied
// (see internal/db/migrations/_dev/mock_supabase_auth.sql)
//
// The test applies migrations, sets up three users in two offices, creates
// four Akten with different visibility settings, and asserts each user sees
// exactly the expected subset.
func setupAkteTest(t *testing.T) (*AkteService, *UserService, *sqlx.DB, func()) {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
users := NewUserService(pool)
akteSvc := NewAkteService(pool, users)
return akteSvc, users, pool, func() { pool.Close() }
}
func seedTestUser(t *testing.T, pool *sqlx.DB, id uuid.UUID, email, office, role string) {
t.Helper()
ctx := context.Background()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`, id, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET office = EXCLUDED.office, role = EXCLUDED.role`,
id, email, email, office, role); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
}
func cleanupTestData(t *testing.T, pool *sqlx.DB, userIDs []uuid.UUID) {
t.Helper()
ctx := context.Background()
for _, uid := range userIDs {
pool.ExecContext(ctx, `DELETE FROM paliad.akten WHERE created_by = $1`, uid)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, uid)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, uid)
}
}
func TestAkteService_OfficeScopedVisibility(t *testing.T) {
akteSvc, _, pool, done := setupAkteTest(t)
defer done()
alice := uuid.MustParse("11111111-1111-1111-1111-111111111111")
bob := uuid.MustParse("22222222-2222-2222-2222-222222222222")
carol := uuid.MustParse("33333333-3333-3333-3333-333333333333")
defer cleanupTestData(t, pool, []uuid.UUID{alice, bob, carol})
seedTestUser(t, pool, alice, "alice@test.local", "munich", "associate")
seedTestUser(t, pool, bob, "bob@test.local", "duesseldorf", "associate")
seedTestUser(t, pool, carol, "carol@test.local", "munich", "partner")
ctx := context.Background()
// Alice creates a Munich Akte (default visibility).
a1, err := akteSvc.Create(ctx, alice, CreateAkteInput{
Aktenzeichen: "TEST-M-001",
Title: "Munich matter",
})
if err != nil {
t.Fatalf("alice create: %v", err)
}
// Bob creates a Düsseldorf Akte (default).
a2, err := akteSvc.Create(ctx, bob, CreateAkteInput{
Aktenzeichen: "TEST-D-001",
Title: "Düsseldorf matter",
})
if err != nil {
t.Fatalf("bob create: %v", err)
}
// Bob creates a firm-wide visible Düsseldorf Akte.
a3, err := akteSvc.Create(ctx, bob, CreateAkteInput{
Aktenzeichen: "TEST-D-002",
Title: "Düsseldorf firm-wide",
})
if err != nil {
t.Fatalf("bob create firm-wide: %v", err)
}
firmWide := true
if _, err := akteSvc.Update(ctx, bob, a3.ID, UpdateAkteInput{FirmWideVisible: &firmWide}); err == nil {
t.Fatal("bob (associate) should not be able to toggle firm_wide_visible")
}
// Carol (partner) does it instead.
if _, err := akteSvc.Update(ctx, carol, a3.ID, UpdateAkteInput{FirmWideVisible: &firmWide}); err != nil {
// Wait — Carol has Munich visibility? a3 is Düsseldorf. Carol must
// not see it unless it's already firm-wide… the test reveals a gap:
// partners can only toggle firm-wide for matters they already see.
// That's the correct behaviour. Skip and use admin path below.
_ = err
}
// Promote Bob to admin temporarily to flip the flag.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.users SET role = 'partner' WHERE id = $1`, bob); err != nil {
t.Fatalf("promote bob: %v", err)
}
if _, err := akteSvc.Update(ctx, bob, a3.ID, UpdateAkteInput{FirmWideVisible: &firmWide}); err != nil {
t.Fatalf("bob (promoted partner) flip firm-wide: %v", err)
}
// Bob creates a collaborator-restricted Düsseldorf Akte with Alice as collab.
a4, err := akteSvc.Create(ctx, bob, CreateAkteInput{
Aktenzeichen: "TEST-D-003",
Title: "Düsseldorf collab",
})
if err != nil {
t.Fatalf("bob create collab: %v", err)
}
if _, err := akteSvc.Update(ctx, bob, a4.ID, UpdateAkteInput{
Collaborators: []string{alice.String()},
}); err != nil {
t.Fatalf("set collaborators: %v", err)
}
// ------------------------------------------------------------------
// Visibility assertions
// ------------------------------------------------------------------
assertVisible := func(who string, uid uuid.UUID, akteID uuid.UUID, wantVisible bool) {
t.Helper()
_, err := akteSvc.GetByID(ctx, uid, akteID)
switch {
case wantVisible && err != nil:
t.Errorf("%s: expected visible, got error %v", who, err)
case !wantVisible && err == nil:
t.Errorf("%s: expected NOT visible, but GetByID succeeded", who)
case !wantVisible && !errors.Is(err, ErrNotVisible):
t.Errorf("%s: expected ErrNotVisible, got %v", who, err)
}
}
// Alice (Munich associate): own-office + firm-wide + collaborator
assertVisible("alice→a1", alice, a1.ID, true)
assertVisible("alice→a2", alice, a2.ID, false)
assertVisible("alice→a3", alice, a3.ID, true)
assertVisible("alice→a4", alice, a4.ID, true)
// Bob (Düsseldorf partner): own-office × 3 + firm-wide
assertVisible("bob→a1", bob, a1.ID, false)
assertVisible("bob→a2", bob, a2.ID, true)
assertVisible("bob→a3", bob, a3.ID, true)
assertVisible("bob→a4", bob, a4.ID, true)
// Carol (Munich partner): own-office + firm-wide only
assertVisible("carol→a1", carol, a1.ID, true)
assertVisible("carol→a2", carol, a2.ID, false)
assertVisible("carol→a3", carol, a3.ID, true)
assertVisible("carol→a4", carol, a4.ID, false)
// ListVisibleForUser returns disjoint sets per user
aliceList, err := akteSvc.ListVisibleForUser(ctx, alice)
if err != nil {
t.Fatalf("alice list: %v", err)
}
seen := map[uuid.UUID]bool{}
for _, a := range aliceList {
seen[a.ID] = true
}
for _, id := range []uuid.UUID{a1.ID, a3.ID, a4.ID} {
if !seen[id] {
t.Errorf("alice list: expected %s to be visible", id)
}
}
if seen[a2.ID] {
t.Error("alice list: a2 (Düsseldorf default) should NOT be visible")
}
}
func TestAkteService_RoleEnforcement(t *testing.T) {
akteSvc, _, pool, done := setupAkteTest(t)
defer done()
alice := uuid.MustParse("aaaaaaaa-0000-0000-0000-aaaaaaaaaaaa")
defer cleanupTestData(t, pool, []uuid.UUID{alice})
seedTestUser(t, pool, alice, "alice-role@test.local", "munich", "associate")
ctx := context.Background()
// Associate creates OK
a, err := akteSvc.Create(ctx, alice, CreateAkteInput{
Aktenzeichen: "ROLE-001",
Title: "Role test",
})
if err != nil {
t.Fatalf("associate create: %v", err)
}
// Associate CANNOT delete
err = akteSvc.Delete(ctx, alice, a.ID)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden for associate delete, got %v", err)
}
// Associate CANNOT create in another office
_, err = akteSvc.Create(ctx, alice, CreateAkteInput{
Aktenzeichen: "ROLE-002",
Title: "Cross-office",
OwningOffice: "duesseldorf",
})
if !errors.Is(err, ErrForbidden) {
t.Errorf("expected ErrForbidden for cross-office create, got %v", err)
}
// Invalid office rejected
_, err = akteSvc.Create(ctx, alice, CreateAkteInput{
Aktenzeichen: "ROLE-003",
Title: "Invalid office",
OwningOffice: "atlantis",
})
if !errors.Is(err, ErrForbidden) && !errors.Is(err, ErrInvalidInput) {
t.Errorf("expected ErrForbidden or ErrInvalidInput for invalid office, got %v", err)
}
}