package services import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/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 noteColumns = `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 noteSelect = `SELECT ` + noteColumns + ` FROM paliad.notes n LEFT JOIN paliad.users u ON u.id = n.created_by` // CreateNoteInput is the POST payload. type CreateNoteInput struct { Content string `json:"content"` } // UpdateNoteInput is the PATCH payload. type UpdateNoteInput struct { Content *string `json:"content,omitempty"` } // ListForProject returns all notes attached directly to the given Project. func (s *NoteService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Note, error) { if err := s.requireProjectVisible(ctx, userID, projectID); err != nil { return nil, err } return s.list(ctx, `n.project_id = $1`, projectID) } // ListForDeadline returns all notes attached to a specific Deadline. func (s *NoteService) ListForDeadline(ctx context.Context, userID, deadlineID uuid.UUID) ([]models.Note, error) { projectID, err := s.deadlineProjectID(ctx, deadlineID) if err != nil { return nil, err } if err := s.requireProjectVisible(ctx, userID, projectID); err != nil { return nil, err } return s.list(ctx, `n.deadline_id = $1`, deadlineID) } // ListForAppointment returns all notes attached to a specific Appointment. func (s *NoteService) ListForAppointment(ctx context.Context, userID, appointmentID uuid.UUID) ([]models.Note, error) { if err := s.requireAppointmentVisible(ctx, userID, appointmentID); err != nil { return nil, err } return s.list(ctx, `n.appointment_id = $1`, appointmentID) } // ListForProjectEvent returns all notes attached to a specific project_event row. func (s *NoteService) ListForProjectEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Note, error) { projectID, err := s.eventProjectID(ctx, eventID) if err != nil { return nil, err } if err := s.requireProjectVisible(ctx, userID, projectID); err != nil { return nil, err } return s.list(ctx, `n.project_event_id = $1`, eventID) } // CreateForProject inserts a note attached directly to a Project. func (s *NoteService) CreateForProject(ctx context.Context, userID, projectID uuid.UUID, input CreateNoteInput) (*models.Note, error) { if err := s.requireProjectVisible(ctx, userID, projectID); 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: &projectID}, &projectID, "project") if err != nil { return nil, err } return s.getByIDUnchecked(ctx, id) } // CreateForDeadline inserts a note attached to a Deadline. func (s *NoteService) CreateForDeadline(ctx context.Context, userID, deadlineID uuid.UUID, input CreateNoteInput) (*models.Note, error) { projectID, err := s.deadlineProjectID(ctx, deadlineID) if err != nil { return nil, err } if err := s.requireProjectVisible(ctx, userID, projectID); 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: &deadlineID}, &projectID, "deadline") if err != nil { return nil, err } return s.getByIDUnchecked(ctx, id) } // CreateForAppointment inserts a note attached to an Appointment. Personal // Appointment notes skip the audit trail; Project-attached Appointment notes // append events. func (s *NoteService) CreateForAppointment(ctx context.Context, userID, appointmentID uuid.UUID, input CreateNoteInput) (*models.Note, error) { t, err := s.appointment.GetByID(ctx, userID, appointmentID) 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: &appointmentID}, 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 UpdateNoteInput) (*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.GlobalRole != "global_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 := noteSelect + ` 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, projectAuditID *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 projectAuditID != nil { // Description carries the value-only payload (the parent slug); the // frontend renders it via the localized event.note.added_to template. // Metadata carries the most-specific anchor so the Verlauf entry can // deep-link to the deadline/appointment/project the note hangs on // (notes don't have their own page — t-paliad-102). title := "Note added" desc := parentLabel descPtr := &desc meta := map[string]any{"note_id": id} switch { case parent.DeadlineID != nil: meta["deadline_id"] = *parent.DeadlineID case parent.AppointmentID != nil: meta["appointment_id"] = *parent.AppointmentID case parent.ProjectID != nil: meta["project_id"] = *parent.ProjectID } if err := insertProjectEventWithMeta(ctx, tx, *projectAuditID, userID, "note_created", title, descPtr, meta); 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, noteSelect+` 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: return s.requireProjectVisible(ctx, userID, *n.ProjectID) case n.DeadlineID != nil: projectID, err := s.deadlineProjectID(ctx, *n.DeadlineID) if err != nil { return err } return s.requireProjectVisible(ctx, userID, projectID) case n.AppointmentID != nil: return s.requireAppointmentVisible(ctx, userID, *n.AppointmentID) case n.ProjectEventID != nil: projectID, err := s.eventProjectID(ctx, *n.ProjectEventID) if err != nil { return err } return s.requireProjectVisible(ctx, userID, projectID) default: return ErrNotVisible } } // requireProjectVisible returns ErrNotVisible if the user can't see the // Project. Used to gate note operations without paying for a full row read. func (s *NoteService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error { visible, err := s.projects.CanSee(ctx, userID, projectID) if err != nil { return err } if !visible { return ErrNotVisible } return nil } // requireAppointmentVisible returns ErrNotVisible if the user can't see the // Appointment. Used to gate note operations without paying for a full row read. func (s *NoteService) requireAppointmentVisible(ctx context.Context, userID, appointmentID uuid.UUID) error { visible, err := s.appointment.CanSee(ctx, userID, appointmentID) if err != nil { return err } if !visible { return ErrNotVisible } return nil } func (s *NoteService) deadlineProjectID(ctx context.Context, deadlineID uuid.UUID) (uuid.UUID, error) { var projectID uuid.UUID err := s.db.GetContext(ctx, &projectID, `SELECT project_id FROM paliad.deadlines WHERE id = $1`, deadlineID) if errors.Is(err, sql.ErrNoRows) { return uuid.Nil, ErrNotVisible } if err != nil { return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err) } return projectID, nil } func (s *NoteService) eventProjectID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) { var projectID uuid.UUID err := s.db.GetContext(ctx, &projectID, `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 projectID, 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 }