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.
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/models"
|
|
)
|
|
|
|
// NoteService reads and writes paliad.notes — polymorphic notes anchored
|
|
// to exactly one of { Project, Deadline, Appointment, ProjectEvent }. Visibility
|
|
// follows the parent row.
|
|
//
|
|
// Edit: only the author (created_by) may edit their own note.
|
|
// Delete: author, or partner/admin.
|
|
type NoteService struct {
|
|
db *sqlx.DB
|
|
projects *ProjectService
|
|
appointment *AppointmentService
|
|
}
|
|
|
|
func NewNoteService(db *sqlx.DB, projects *ProjectService, appointment *AppointmentService) *NoteService {
|
|
return &NoteService{db: db, projects: projects, appointment: appointment}
|
|
}
|
|
|
|
const notizColumns = `n.id, n.project_id, n.deadline_id, n.appointment_id, n.project_event_id,
|
|
n.content, n.created_by, n.created_at, n.updated_at,
|
|
u.display_name AS author_name,
|
|
u.email AS author_email`
|
|
|
|
const notizSelect = `SELECT ` + notizColumns + `
|
|
FROM paliad.notes n
|
|
LEFT JOIN paliad.users u ON u.id = n.created_by`
|
|
|
|
// CreateNotizInput is the POST payload.
|
|
type CreateNotizInput struct {
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// UpdateNotizInput is the PATCH payload.
|
|
type UpdateNotizInput struct {
|
|
Content *string `json:"content,omitempty"`
|
|
}
|
|
|
|
// ListForProjekt returns all notes attached directly to the given Project.
|
|
func (s *NoteService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Note, error) {
|
|
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.list(ctx, `n.project_id = $1`, projektID)
|
|
}
|
|
|
|
// ListForFrist returns all notes attached to a specific Deadline.
|
|
func (s *NoteService) ListForFrist(ctx context.Context, userID, fristID uuid.UUID) ([]models.Note, error) {
|
|
projektID, err := s.fristProjectID(ctx, fristID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.list(ctx, `n.deadline_id = $1`, fristID)
|
|
}
|
|
|
|
// ListForTermin returns all notes attached to a specific Appointment.
|
|
func (s *NoteService) ListForTermin(ctx context.Context, userID, terminID uuid.UUID) ([]models.Note, error) {
|
|
if _, err := s.appointment.GetByID(ctx, userID, terminID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.list(ctx, `n.appointment_id = $1`, terminID)
|
|
}
|
|
|
|
// ListForProjectEvent returns all notes attached to a specific projekt_event row.
|
|
func (s *NoteService) ListForProjectEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Note, error) {
|
|
projektID, err := s.eventProjectID(ctx, eventID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.list(ctx, `n.project_event_id = $1`, eventID)
|
|
}
|
|
|
|
// CreateForProjekt inserts a note attached directly to a Project.
|
|
func (s *NoteService) CreateForProjekt(ctx context.Context, userID, projektID uuid.UUID, input CreateNotizInput) (*models.Note, error) {
|
|
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := validateContent(input.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := s.insertWithAudit(ctx, userID, content, noteParent{ProjectID: &projektID}, &projektID, "project")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.getByIDUnchecked(ctx, id)
|
|
}
|
|
|
|
// CreateForFrist inserts a note attached to a Deadline.
|
|
func (s *NoteService) CreateForFrist(ctx context.Context, userID, fristID uuid.UUID, input CreateNotizInput) (*models.Note, error) {
|
|
projektID, err := s.fristProjectID(ctx, fristID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := validateContent(input.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := s.insertWithAudit(ctx, userID, content, noteParent{DeadlineID: &fristID}, &projektID, "deadline")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.getByIDUnchecked(ctx, id)
|
|
}
|
|
|
|
// CreateForTermin inserts a note attached to a Appointment. Personal Appointment
|
|
// notes skip the audit trail; Project-attached Appointment notes append events.
|
|
func (s *NoteService) CreateForTermin(ctx context.Context, userID, terminID uuid.UUID, input CreateNotizInput) (*models.Note, error) {
|
|
t, err := s.appointment.GetByID(ctx, userID, terminID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := validateContent(input.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := s.insertWithAudit(ctx, userID, content, noteParent{AppointmentID: &terminID}, t.ProjectID, "appointment")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.getByIDUnchecked(ctx, id)
|
|
}
|
|
|
|
// GetByID returns a single note, visibility-checked.
|
|
func (s *NoteService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Note, error) {
|
|
n, err := s.getByIDUnchecked(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.requireVisible(ctx, userID, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Update edits a note's content. Only the original author may edit.
|
|
func (s *NoteService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateNotizInput) (*models.Note, error) {
|
|
current, err := s.GetByID(ctx, userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
|
return nil, fmt.Errorf("%w: only the author can edit a Note", ErrForbidden)
|
|
}
|
|
if input.Content == nil {
|
|
return current, nil
|
|
}
|
|
content, err := validateContent(*input.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`UPDATE paliad.notes SET content = $1, updated_at = NOW() WHERE id = $2`,
|
|
content, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update note: %w", err)
|
|
}
|
|
return s.getByIDUnchecked(ctx, id)
|
|
}
|
|
|
|
// Delete removes a note. Author, partner, or admin only.
|
|
func (s *NoteService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
|
current, err := s.GetByID(ctx, userID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
isAuthor := current.CreatedBy != nil && *current.CreatedBy == userID
|
|
if !isAuthor {
|
|
user, err := s.projects.Users().GetByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil || (user.Role != "partner" && user.Role != "admin") {
|
|
return fmt.Errorf("%w: only the author or a partner/admin can delete a Note", ErrForbidden)
|
|
}
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM paliad.notes WHERE id = $1`, id); err != nil {
|
|
return fmt.Errorf("delete note: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- internals -------------------------------------------------------------
|
|
|
|
type noteParent struct {
|
|
ProjectID *uuid.UUID
|
|
DeadlineID *uuid.UUID
|
|
AppointmentID *uuid.UUID
|
|
ProjectEventID *uuid.UUID
|
|
}
|
|
|
|
func (s *NoteService) list(ctx context.Context, where string, arg any) ([]models.Note, error) {
|
|
query := notizSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC`
|
|
rows := []models.Note{}
|
|
if err := s.db.SelectContext(ctx, &rows, query, arg); err != nil {
|
|
return nil, fmt.Errorf("list notes: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// insertWithAudit inserts one notes row and, when an owning Project exists,
|
|
// appends a project_events audit row in the same transaction.
|
|
func (s *NoteService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent noteParent, projektAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) {
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.notes
|
|
(id, project_id, deadline_id, appointment_id, project_event_id,
|
|
content, created_by, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
|
|
id, parent.ProjectID, parent.DeadlineID, parent.AppointmentID, parent.ProjectEventID,
|
|
content, userID, now,
|
|
); err != nil {
|
|
return uuid.Nil, fmt.Errorf("insert note: %w", err)
|
|
}
|
|
|
|
if projektAuditID != nil {
|
|
title := "Note added"
|
|
desc := fmt.Sprintf("Note zu %s hinzugef\u00fcgt", parentLabel)
|
|
descPtr := &desc
|
|
if err := insertProjectEvent(ctx, tx, *projektAuditID, userID, "note_created", title, descPtr); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return uuid.Nil, fmt.Errorf("commit insert note: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// getByIDUnchecked fetches a note without a visibility check.
|
|
func (s *NoteService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.Note, error) {
|
|
var n models.Note
|
|
err := s.db.GetContext(ctx, &n, notizSelect+` WHERE n.id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch note: %w", err)
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
// requireVisible re-runs the parent-visibility check.
|
|
func (s *NoteService) requireVisible(ctx context.Context, userID uuid.UUID, n *models.Note) error {
|
|
switch {
|
|
case n.ProjectID != nil:
|
|
_, err := s.projects.GetByID(ctx, userID, *n.ProjectID)
|
|
return err
|
|
case n.DeadlineID != nil:
|
|
projektID, err := s.fristProjectID(ctx, *n.DeadlineID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.projects.GetByID(ctx, userID, projektID)
|
|
return err
|
|
case n.AppointmentID != nil:
|
|
_, err := s.appointment.GetByID(ctx, userID, *n.AppointmentID)
|
|
return err
|
|
case n.ProjectEventID != nil:
|
|
projektID, err := s.eventProjectID(ctx, *n.ProjectEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.projects.GetByID(ctx, userID, projektID)
|
|
return err
|
|
default:
|
|
return ErrNotVisible
|
|
}
|
|
}
|
|
|
|
func (s *NoteService) fristProjectID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
|
|
var projektID uuid.UUID
|
|
err := s.db.GetContext(ctx, &projektID,
|
|
`SELECT project_id FROM paliad.deadlines WHERE id = $1`, fristID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return uuid.Nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
|
|
}
|
|
return projektID, nil
|
|
}
|
|
|
|
func (s *NoteService) eventProjectID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) {
|
|
var projektID uuid.UUID
|
|
err := s.db.GetContext(ctx, &projektID,
|
|
`SELECT project_id FROM paliad.project_events WHERE id = $1`, eventID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return uuid.Nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("lookup event parent: %w", err)
|
|
}
|
|
return projektID, nil
|
|
}
|
|
|
|
func validateContent(raw string) (string, error) {
|
|
content := strings.TrimSpace(raw)
|
|
if content == "" {
|
|
return "", fmt.Errorf("%w: content is required", ErrInvalidInput)
|
|
}
|
|
if len(content) > 10000 {
|
|
return "", fmt.Errorf("%w: content exceeds 10000 characters", ErrInvalidInput)
|
|
}
|
|
return content, nil
|
|
}
|