fix(projects-detail, services): empty-list endpoints returned JSON null → tab content blank

m reported /projects/{id} loaded the chrome and tabs but every panel was
empty even with deadlines/appointments/team rows that should render.
Console error: "Cannot read properties of null (reading 'length')" at
projects-detail.js — the Project Detail page expects every list endpoint
to return [] but at least two were returning literal JSON null.

Reproduced via the in-page fetch console:
  /api/projects/{id}/parties   → 200, body: "null"
  /api/projects/{id}/children  → 200, body: "null"
  /api/projects/{id}/deadlines → 200, body: "[…]"   (had data, fine)
  /api/projects/{id}/team      → 200, body: "[…]"   (had data, fine)

Root cause: every list service in internal/services declared its result
as `var rows []models.X` and returned that to the handler, which
encoding/json marshals as `null` when the SELECT returns zero rows
(nil slice, not empty slice). Most endpoints happen to have data so
the bug stayed dormant until t-paliad-038 hit /projects/{id} where
parties + children are commonly empty.

Fix at the source — every list service that JSON-marshals to a client
now initialises `rows := []models.X{}` so the encoder produces `[]`:

  party_service        ListForProjekt
  project_service      List, ListAncestors, BuildTree, GetTree
                       (ListChildren goes through List)
  deadline_service     List + ListForProjekt
  appointment_service  List + ListForProjekt
  note_service         ListForProjekt
  checklist_instance_service  ListForProjekt
  team_service         List
  department_service   List + ListMembers + ListWithMembers

caldav_service was deliberately left alone — its lists are admin-only
debug surfaces, not user-facing tab fillers, and changing them would
mix scopes.

Belt-and-braces on the client too — projects-detail.ts now coerces every
`await resp.json()` for an array endpoint with `?? []` so a future
service regression can't crash the page.

Verified: go build/vet/test clean, bun run build clean.
This commit is contained in:
m
2026-04-26 01:44:09 +02:00
parent 3ff982cc51
commit 70c3f08668
9 changed files with 25 additions and 25 deletions

View File

@@ -142,7 +142,7 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
}
defer stmt.Close()
var rows []models.AppointmentWithProject
rows := []models.AppointmentWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list appointments: %w", err)
}
@@ -154,7 +154,7 @@ func (s *AppointmentService) ListForProjekt(ctx context.Context, userID, projekt
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Appointment
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+terminColumns+`
FROM paliad.appointments

View File

@@ -332,7 +332,7 @@ func (s *ChecklistInstanceService) listWithProjekt(ctx context.Context, query st
}
defer stmt.Close()
var rows []models.ChecklistInstanceWithProject
rows := []models.ChecklistInstanceWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list checklist_instances: %w", err)
}

View File

@@ -139,7 +139,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
}
defer stmt.Close()
var rows []models.DeadlineWithProject
rows := []models.DeadlineWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list deadlines: %w", err)
}
@@ -151,7 +151,7 @@ func (s *DeadlineService) ListForProjekt(ctx context.Context, userID, projektID
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Deadline
rows := []models.Deadline{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+fristColumns+`
FROM paliad.deadlines

View File

@@ -45,7 +45,7 @@ type UpdateDepartmentInput struct {
// List returns every Dezernat (readable by any authenticated user — see RLS).
func (s *DepartmentService) List(ctx context.Context) ([]models.Department, error) {
var rows []models.Department
rows := []models.Department{}
err := s.db.SelectContext(ctx, &rows,
`SELECT id, name, lead_user_id, office, created_at, updated_at
FROM paliad.departments
@@ -281,7 +281,7 @@ func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWi
// GetMembership returns the user's Dezernat memberships (zero or more).
// Used by the settings page to render "Your Dezernat: <name>".
func (s *DepartmentService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Department, error) {
var rows []models.Department
rows := []models.Department{}
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at
FROM paliad.departments d

View File

@@ -214,7 +214,7 @@ type noteParent struct {
func (s *NoteService) list(ctx context.Context, where string, arg any) ([]models.Note, error) {
query := notizSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC`
var rows []models.Note
rows := []models.Note{}
if err := s.db.SelectContext(ctx, &rows, query, arg); err != nil {
return nil, fmt.Errorf("list notes: %w", err)
}

View File

@@ -43,7 +43,7 @@ func (s *PartyService) ListForProjekt(ctx context.Context, userID, projektID uui
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Party
rows := []models.Party{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+parteiColumns+`
FROM paliad.parties

View File

@@ -192,7 +192,7 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi
}
defer stmt.Close()
var rows []models.Project
rows := []models.Project{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
@@ -269,7 +269,7 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID
for i, u := range ids {
idStrs[i] = u.String()
}
var rows []models.Project
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil {
return nil, fmt.Errorf("list ancestors: %w", err)
}
@@ -309,7 +309,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
query := `SELECT ` + projektColumns + ` FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1, 2) + `
ORDER BY p.path`
var rows []models.Project
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
return nil, fmt.Errorf("build tree list: %w", err)
}
@@ -401,7 +401,7 @@ func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m
WHERE (p.path = $1 OR p.path LIKE $2)
AND ` + visibilityPredicatePositional("p", 3, 4) + `
ORDER BY p.path`
var rows []models.Project
rows := []models.Project{}
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil {
return nil, fmt.Errorf("get tree: %w", err)
}

View File

@@ -88,7 +88,7 @@ func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID
if _, err := s.projects.GetByID(ctx, callerID, projektID); err != nil {
return nil, err
}
var rows []models.ProjectTeamMemberWithUser
rows := []models.ProjectTeamMemberWithUser{}
err := s.db.SelectContext(ctx, &rows,
`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.inherited,
pt.added_by, pt.created_at,
@@ -145,7 +145,7 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projek
WHERE r.rn = 1
ORDER BY r.inherited ASC, r.role, u.display_name`
var rows []models.ProjectTeamMemberWithUser
rows := []models.ProjectTeamMemberWithUser{}
if err := s.db.SelectContext(ctx, &rows, query, projektID, pq.StringArray(ancestorIDs)); err != nil {
return nil, fmt.Errorf("list effective team: %w", err)
}