Files
paliad/internal/services/note_service.go
m f583c650a2 fix(t-paliad-067): PR-1 i18n leak sweep + activity narrative (F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46)
Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.

- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
  keys (the SSR template already used them) instead of the non-existent
  fristen.field.project.* keys, so the picker no longer renders the raw
  i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
  • i18n.ts gains the missing dashboard.action.short.project_type_changed
    plus a parallel event.title.* key set (full noun-phrase form for
    Verlauf, complementing the dashboard's verb form) and
    event.description.* templates with {title}/{count}/{parent}
    placeholders.
  • New translateEvent(eventType, title, description) helper localizes a
    stored project_events row for display; parses both new value-only
    descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
    geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
    Wired into dashboard.ts and projects-detail.ts renderers.
  • Go services now write descriptions as value-only payloads (the title,
    the count, the parent slug, or "old → new") so future rows are
    locale-clean. Affected services: deadline_service.go (5 sites),
    appointment_service.go (3 sites), note_service.go (1 site),
    project_service.go (2 sites: status_changed, project_type_changed).
  • Translation covers historical project_events rows too — the
    legacy-format parsers in translateEventDescription strip the English
    "Type"/"Status" prefix and pull the quoted title out of "Deadline
    „Foo" geändert" so DE/EN renders correctly without DB migration.
  • Renamed dashboard.action.short.project_* DE labels from "...Akte" to
    "...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
  via a JOIN-side alias on deadline_service.ListWithProjects (added
  RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
  prefers the localized rule name and falls back to em-dash; never
  renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
  "Projekt"; matching SSR placeholder text on deadlines.tsx and
  appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
  split into prefix/link/suffix spans so the <a href="/checklists"> stays
  intact after applyTranslations() runs (the previous single-string i18n
  value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
  actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
  Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
  case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".

Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
  reading the translated paths; live smoke pending deploy.
2026-04-29 14:26:04 +02:00

340 lines
11 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.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 := 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 {
// Description carries the value-only payload (the parent slug); the
// frontend renders it via the localized event.note.added_to template.
title := "Note added"
desc := 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
}